diff --git a/AGENTS.md b/AGENTS.md index 50e00dcc20..22c1f47aa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend + context/ request-context plugins subagent/ subagent seam + spawn/fork/ACP backends + delegation tool workflow/ workflow seam + worker-thread engine + the workflow tool todo/ the todo_write tool @@ -34,7 +35,7 @@ docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see scripts/ repo gates and generators ``` -Per-package map: the group READMEs, indexed from [packages/README.md](packages/README.md). +Package groups: [packages/README.md](packages/README.md). ## Commands @@ -84,14 +85,14 @@ pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests ## Secrets / .env -Real-API tests and demos read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the environment or a gitignored root `.env` loaded by `process.loadEnvFile()`. cordis.yml uses `!!js` (never `!js`) for env vars. Never commit credentials. CI e2e self-skips without a key; [docs/testing.md](docs/testing.md) owns the with-key policy. +Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) only under plugin `config`; Loader metadata is static, so conditional composition uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Typed events use declaration merging**; extensible unions use merge-extensible maps. Event JSDoc needs `@mode` and payload `@param` tags; public service methods document parameters and non-void returns. Catalog gates enforce this. +- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. diff --git a/docs/architecture.md b/docs/architecture.md index d9c3d86a1e..b17c3302d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0f7436c1e6..52da348228 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -140,7 +140,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:21`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:18`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -256,7 +256,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:49`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:35`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -608,6 +608,22 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts) +## `@deepseek-ai/dsh-stdio` + +Requires: `agents` · `userInteraction` + +```ts config-catalog +/** Serializable plugin configuration (cordis-native, schemastery). */ +export interface Config { + /** Banner printed once on start, before the first `> ` prompt. */ + welcome?: string + /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ + agent?: string +} +``` + +Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) + ## `@deepseek-ai/dsh-stdio-agent` ```ts config-catalog @@ -780,6 +796,22 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:147`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-time-context` + +Requires: `systemPrompt` + +```ts config-catalog +/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +export interface Config { + /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ + timeZone?: string + /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` @@ -816,7 +848,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:31`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` @@ -951,7 +983,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:308`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1001,7 +1033,7 @@ export interface WebServiceConfig { } ``` -Source: [`packages/web/web/src/index.ts:59`](../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -1016,10 +1048,8 @@ export interface Config { maxResponseBytes?: number /** Maximum decoded body length in characters. */ maxBodyChars?: number - /** Default fetch timeout in milliseconds. */ + /** Default fetch timeout in milliseconds, within Node's timer range. */ timeoutMs?: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number /** `User-Agent` header sent on every request. */ @@ -1027,7 +1057,7 @@ export interface Config { } ``` -Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts) +Source: [`packages/web/web-fetch-local/src/index.ts:36`](../packages/web/web-fetch-local/src/index.ts) ## `@deepseek-ai/dsh-web-search-deepseek` @@ -1123,7 +1153,7 @@ export interface Config { } ``` -Source: [`packages/workflow/workflow-workerthread/src/index.ts:36`](../packages/workflow/workflow-workerthread/src/index.ts) +Source: [`packages/workflow/workflow-workerthread/src/index.ts:32`](../packages/workflow/workflow-workerthread/src/index.ts) ## Loadable plugins with no config @@ -1162,6 +1192,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts)) +- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml new file mode 100644 index 0000000000..27c31ba1ef --- /dev/null +++ b/docs/cookbook/adding-a-package.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 +adding-a-package.md: 2930cee9ab64b382f6211335ae639bce45629d1d +adding-a-package.zh.md: 10e906c320203c3658c103fc8936540f72697d65 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index db0684b762..2930cee9ab 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -1,6 +1,8 @@ # Cookbook: adding a workspace package -The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verified by the bash and adapter packages; if it drifts, fix it here.) +English | [中文](adding-a-package.zh.md) + +The file-by-file checklist for a new `@deepseek-ai/dsh-` package. This checklist is validated against the bash and adapter packages as templates; if it drifts from them, fix it here. ## 1. Create the package diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md new file mode 100644 index 0000000000..10e906c320 --- /dev/null +++ b/docs/cookbook/adding-a-package.zh.md @@ -0,0 +1,83 @@ +# 实操手册:添加 workspace 包(package) + +[English](adding-a-package.md) | 中文 + +为新建 `@deepseek-ai/dsh-` 包提供的逐文件清单。本清单以 bash 和 adapter 这两个包为模板进行验证;如果清单与模板有出入,请在此修正。 + +## 1. 创建包 + +``` +packages/// + package.json # copy from packages/core/tools, adjust name/description/deps + tsconfig.json # extends ../../../tsconfig.base.json, rootDir src, + # outDir lib/types, references: ../../../vendor/cosmokit, + # ../../../vendor/cordis (+ ../../../vendor/schemastery if + # you use Config, + ../..// for each dsh dep) + src/index.ts # service default export or plugin (name/inject/apply/Config) + tests/.spec.ts + README.md # service API, events, extension points, design notes, + # + gated Model Experience context blocks or short sentence + # + the gated "Known Limitations and Deferred Work" section + # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) +``` + +当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 + +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表要精确:`lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map` 和 `src`;不要发布 `lib/types` 下的 JS 或 JS-map 中间产物,也不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 + +包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 + +## 2. 在根配置中注册 + +| 文件 | 变更 | +|---|---| +| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | +| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) | + +以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。 + +## 3. 确定包拓扑 + +对于可替换的能力,将接口、实现、消费方拆分为独立的包(见 docs/architecture.md § "Capability seams"——bash 三组件是模板)。单一用途的插件保持为一个包。 + +## 4. 编写包 README + +将包特有的服务 API、配置、事件、扩展点和设计说明放在前面。limitations 部分记录持久的消费方缺口和本包拥有的非显而易见的维护者约束;日常清理事项留在源码 TODO 或 RFC 中。间接的 Model Experience 语句可以点名暴露本包贡献的消费方,但不重述该消费方的实现。包 README 以如下规范序列结尾: + +````markdown +## Model Experience + +### Request surface and condition + +**What the model sees**: An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. + +**Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. + +#### Verbatim text for this context surface, when needed + +```markdown +Stable system-prompt prose of any length, or another long non-generated literal, copied exactly from source. +``` + +## Known Limitations and Deferred Work + +- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +```` + +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述两个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 + +没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 + +## 5. 验证 + +```sh +pnpm install # registers the workspace +pnpm run doc-sync +pnpm run constraints && pnpm run typecheck && pnpm run lint +pnpm run test:coverage # 100% per-file over src (types.ts exempt) +pnpm run build && pnpm run hygiene +``` + +测试要求:每个注册表/注册操作都需要一个 HMR(热模块替换)安全测试(从子 fiber 注册,dispose(资源释放)它,断言清理完成)。鼓励编写充分的测试——见 [docs/testing.md](../testing.md)。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml new file mode 100644 index 0000000000..7ff1ffff7a --- /dev/null +++ b/docs/cookbook/adding-a-tool.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 +adding-a-tool.md: 4920c98894326fb8eab3b3d5df298baf1da33c1d +adding-a-tool.zh.md: 3caf1e62f15f2f15103836b4d3f22be20ba02385 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 86803fdebb..4920c98894 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -1,5 +1,7 @@ # Cookbook: adding a tool +English | [中文](adding-a-tool.zh.md) + How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam). ## The minimal shape @@ -49,7 +51,7 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task ## 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)), `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 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). ## Code Mode reaches your tool for free diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md new file mode 100644 index 0000000000..3caf1e62f1 --- /dev/null +++ b/docs/cookbook/adding-a-tool.zh.md @@ -0,0 +1,85 @@ +# 实操手册:添加工具 + +[English](adding-a-tool.md) | 中文 + +如何为模型赋予一项新能力。参考实现:`examples/echo-agent/src/echo-tool.ts`(最小化)和 `packages/bash/tool-bash`(生产级,由三个包(package)构成的 seam)。 + +## 最小形态 + +```ts +import { readFile } from 'node:fs/promises' +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'my-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'read_file', + description: 'Read a file from disk.', // what the model sees + parameters: { + path: { type: 'string', required: true, description: 'Absolute path' }, + limit: { type: 'number' }, // optional by default + }, + async execute(args, exec) { + // args is TYPED from the schema: { path: string; limit?: number } + // exec carries immutable identity + token; signal is the operational field + return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + }, + })) +} +``` + +注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具(请编写 HMR(热模块替换)测试)。schema 会自动流入系统提示词的组装过程。 + +## execute() 契约的规则 + +- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。 +- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 +- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 +- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 +- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 +- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。 +- **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。 + +## 长时间运行的工作 + +遵循 tool-bash 的后台模式:`run_in_background` 标志立即返回一个 task id;配套工具增量轮询和终止;完成通知通过 `agent.inject()` 到达。限定缓冲区大小,将完整输出溢写到磁盘,避免静默丢失。 + +> TODO: 目前每个工具都手动重新实现这套后台模式。未来需要一个通用的长时间运行工具层,统一处理 task id、增量轮询、终止和完成通知。 + +## 执行策略与观测 + +尽量不要把部署策略内建到工具中。使用 `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)。 + +## Code Mode 自动触达你的工具 + +在 [Code Mode](../../packages/core/tools/README.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。SDK 从同一份 JSON Schema 派生参数,调用重新进入正常的执行流水线。请将描述写成面向模型的 API 文档;非文本结果块在程序中变为占位符。 + +## 工具在编辑器中的渲染方式(ACP 展示) + +工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。 + +两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型: + +- `presentCall(args)` → 一个 `ToolCallView`(PENDING 卡片): + - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`——默认。设置 `kind` 获取图标(`read`/`search`/…);设置 `locations: [{ path, line? }]` 标注工具涉及的文件,使有能力的编辑器跟随/跳转。 + - `{ card: 'terminal', title, description?, cwd? }`——你的调用本身就是 shell 命令。`title` 是命令,`description` 渲染在终端卡片上方。(tool-bash。) + - `{ card: 'diff', title, diffs, locations? }`——你的调用创建或修改文件。`diffs: [{ path, oldText, newText }]`(新文件时 `oldText: null`)渲染为内联 diff 卡片。(tool-fs `write`/`edit`。) +- `presentResult(args, { content, isError, meta? })` 返回完成后的卡片: + - `generic` 提供可选的标题和内容。 + - `terminal` 提供原始输出和可选的退出元数据;桥接层渲染能力特定或围栏回退视图。 + - `diff` 提供已应用的 hunk,通常由持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。 + +硬性规则(违反会出问题): + +- **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);**桥接层**(而非工具)填充会话 cwd 并相对化展示路径标题。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下——那属于桥接层或未来的 result-event 形态,不属于展示器。 +- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径——这些都不得出现在 `execute` 返回给模型的内容中;它们只存在于展示层。(`terminal` 结果视图携带原始 `output`;桥接层添加围栏。) +- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。 + +中性词汇定义在 `dsh-tools` 中(绝不在工具中导入 ACP 类型);ACP 桥接层将每个 `card` 映射到协议格式(wire format)。设计与原因见[渲染意图联合体 RFC](../rfc/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 快照](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml new file mode 100644 index 0000000000..0a359ead84 --- /dev/null +++ b/docs/cookbook/adding-a-vendored-package.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 +adding-a-vendored-package.md: d7b5b93b59fb39d8369be6eb42fb0a8b977c68b4 +adding-a-vendored-package.zh.md: 86b1e6c959180ba15b6fcb56b6dfe5a3be791b47 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 59df2f617b..d7b5b93b59 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -1,5 +1,7 @@ # Cookbook: adding a vendored package +English | [中文](adding-a-vendored-package.zh.md) + When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-http`), it is **vendored** as pinned source under `vendor/`, not added as an npm dependency — see [the vendoring decision](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md) for why. [vendor/README.md](../../vendor/README.md) covers *updating* an already-vendored package; this guide is the file-by-file checklist for adding a **new** one. (Verified against the existing vendored set; if it drifts, fix it here.) ## 1. Copy the source in diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md new file mode 100644 index 0000000000..86b1e6c959 --- /dev/null +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -0,0 +1,60 @@ +# 实操手册:添加一个 vendored 包(package) + +[English](adding-a-vendored-package.md) | 中文 + +当 harness 需要引入另一个上游 Cordis 包(如 `@cordisjs/plugin-http`)时,应将其作为固定版本的源码 **vendor** 到 `vendor/` 下,而非作为 npm 依赖添加——原因见[vendoring 决策](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md)。[vendor/README.md](../../vendor/README.md) 介绍如何*更新*已有的 vendored 包;本指南是添加**新** vendored 包的逐文件清单。(已对照现有 vendored 集合验证;如有偏差,请在此修正。) + +## 1. 复制源码 + +``` +vendor// + package.json # from upstream; set "private": true, keep name/exports/type + tsconfig.json # extends ../../tsconfig.base.json (see shape below) + src/ # the upstream src/ verbatim + README.md LICENSE # if upstream ships them +``` + +`tsconfig.json` 与其他 vendored 包保持一致:`rootDir: src`、`outDir: lib/types`、上游代码所需的严格性放宽项,以及对所导入的每个其他 vendored 包的 `references` 条目: + +```jsonc +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", "outDir": "lib/types", + "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, + "noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false + }, + "include": ["src"], + "references": [{ "path": "../cordis" }, { "path": "../cosmokit" }] +} +``` + +`package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 + +vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 + +## 2. 在根配置中注册 + +| 文件 | 修改内容 | +|---|---| +| `tsconfig.base.json` | 在 `paths` 中添加 `"": ["./vendor//src"]` | +| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/" }` | +| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `packages/*` 条目之前) | +| `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 | +| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) | + +以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor//tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。 + +## 3. 注意 manifest 守卫 + +`scripts/check-vendor-manifest.sh`(pre-commit 钩子)会在 `vendor/*/src` 下有暂存改动但 `vendor/README.md` 未一起暂存时失败。请将 manifest 更新与源码一起暂存,以通过提交检查。 + +## 4. 验证 + +```sh +pnpm install # registers the workspace +pnpm run typecheck +pnpm run build && pnpm run test && pnpm run constraints +``` + +源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor//tsconfig.json` 被引用,而非被拉入根目录的严格程序中。 diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml new file mode 100644 index 0000000000..37f934f3e6 --- /dev/null +++ b/docs/cookbook/adding-an-llm-adapter.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 +adding-an-llm-adapter.md: 70306ccf119f523dd812a859eef1e8628383bb48 +adding-an-llm-adapter.zh.md: e6d151adfc793a829a876031d5d7280ba078c8e9 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index fb59969e52..70306ccf11 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -1,5 +1,7 @@ # Cookbook: adding an LLM adapter +English | [中文](adding-an-llm-adapter.zh.md) + How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against. ## The shape diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md new file mode 100644 index 0000000000..e6d151adfc --- /dev/null +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -0,0 +1,45 @@ +# 实操手册:添加 LLM 适配器 + +[English](adding-an-llm-adapter.md) | 中文 + +如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。 + +## 基本形态 + +```ts ignore-check +class MyAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { … } +} + +export const name = 'llm-myprovider' +export const inject = ['llm'] +export const Config: z = z.object({ apiKey: z.string(), … }) + +export function apply(ctx: Context, config: Config) { + ctx.llm.registerAdapter(['model-a', 'model-b'], new MyAdapter(…)) +} +``` + +注册基于副作用(HMR 安全);每个模型名称对应一个适配器,重复注册会抛出异常。密钥采用 Cordis 原生方式管理:schemastery Config 带环境变量回退,通过 cordis.yml 的 `!!js process.env.MY_KEY` 注入。代码中禁止临时读取密钥文件。 + +## 协议义务(两个实现共同验证的契约) + +- 在 `finish` **之前**发出 `usage`;`finish` 之后**不再发出任何内容**。稳健做法:缓冲 finish/usage 直到提供方的流结束标记,再统一 flush(可处理提供方在末尾发送仅含 usage 的分片的情况)。 +- 工具调用的 `arguments` 全程为原始 JSON 字符串;流式片段以 `argumentsDelta` 发送。如果你的提供方返回已解析的对象,请在 `block-end` 时重新 stringify。 +- 按首次出现的流顺序分配块 `index`;同一个块的每次 delta 复用该 index。 +- 错误有且仅有两条合法路径:从 `stream()` **抛出**(传输与协议故障——使用带稳定 code 的 `LlmError`),或以 `finish {kind: 'error' | 'aborted'}` 结束流(提供方带内故障)。消费方两者都处理;按故障类别选择路径并加以文档化。 +- 遵守 `options.signal`(将其传递给 fetch 或你的 SDK)。 +- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 + +提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。 + +## 经验证有效的结构 + +将适配器拆分为可测试的阶段(llm-deepseek 的布局):协议格式(wire format)类型(`types.ts`,豁免覆盖率)→ 请求序列化器 → SSE/传输解析器 → 分片转换状态机 → 一个将它们串联的薄适配器类。每个阶段配备独立的单元测试套件。 + +## 测试 + +- **单元测试:mock 提供方,而非 harness。** 用脚本化的 `node:http` 服务器模拟提供方的协议格式,覆盖正常路径、所有错误状态码、畸形载荷、连接提前关闭和中止——无需网络,且能满足 100% 逐文件覆盖率门禁。对基于 SDK 的适配器同样适用(将 SDK 的 baseURL 指向 mock 服务器)。 +- **恶意分帧测试。** 在任意字节位置(包括 UTF-8 字符中间)切割流载荷——真实网络环境正是如此。 +- **E2E:`tests/*.e2e.ts`**,通过 `pnpm run test:e2e` 运行,以 `describe.skipIf(!process.env.MY_KEY)` 守卫,确保无密钥的 CI 保持绿色。覆盖你映射的每个模型 × 每种提供方模式(thinking 开/关、effort 级别)、一次包含后续轮次(历史中带工具结果)的工具调用往返,以及仅做宽松断言(子串/结构匹配、有界的 maxTokens——真实模型是非确定性的)。 +- 在 `knip.json` 中注册 e2e 文件模式(per-workspace `entry` 覆盖),否则 knip 会将其标记为未使用。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml new file mode 100644 index 0000000000..ef1d8d606f --- /dev/null +++ b/docs/cookbook/extension-cookbook.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 +extension-cookbook.md: 40ee22b352c884d7f295c87726c54ab8e166c844 +extension-cookbook.zh.md: 4e5bc68c973649574bcb2404bea00096eb9ca41f diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index a2aa255ccf..40ee22b352 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -1,14 +1,18 @@ # Cookbook: extension plugin shapes +English | [中文](extension-cookbook.zh.md) + +> FIXME: This important guide has not received sufficient human design review; complete that review before the first release. + The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see [adding a package](./adding-a-package.md), [adding a tool](./adding-a-tool.md), and [adding an LLM adapter](./adding-an-llm-adapter.md); for the seams these hook into see [docs/architecture.md](../architecture.md). ## A tool plugin A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](./adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools. -## A hook plugin (permission gate) +## A hook plugin (permission-gate example) -A hook returns a typed decision from the `tools/pre-execute` gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.) +This permission gate is one example of a hook plugin. It returns a typed decision from the `tools/pre-execute` gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this seam. Hook plugins can intercept other seams and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception seam; it needs no external protocol. ```ts import type { Context } from 'cordis' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md new file mode 100644 index 0000000000..4e5bc68c97 --- /dev/null +++ b/docs/cookbook/extension-cookbook.zh.md @@ -0,0 +1,125 @@ +# 实操手册:扩展插件形态 + +[English](extension-cookbook.md) | 中文 + +> FIXME:这篇重要指南尚未经过充分的人工设计审查;请在首次发布前完成审查。 + +针对 harness 扩展表面编写的三种插件形态,以示意性代码片段呈现(省略了 import 和辅助桩——不可直接复制运行)。完整的分步指南见[添加包(package)](./adding-a-package.md)、[添加工具](./adding-a-tool.md)和[添加 LLM(大语言模型)适配器](./adding-an-llm-adapter.md);这些插件所挂接的 seam 见 [docs/architecture.md](../architecture.md)。 + +## 工具插件 + +工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](./adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON-Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。 + +## 钩子插件(以权限门禁为例) + +这个权限门禁是钩子插件的一个示例。它从 `tools/pre-execute` 门禁返回一个类型化的决策,用于允许或拒绝一次调用;沙箱、权限和 plan-mode 插件都可以使用该 seam。钩子插件也可以拦截其他 seam,本身并不等同于权限门禁。「原生钩子」是在拦截 seam 上运行的普通 Cordis 插件,不需要外部协议。 + +```ts +import type { Context } from 'cordis' +import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' + +declare function isAllowed(exec: ToolExecution): Promise + +export const name = 'permission-gate' + +export function apply(ctx: Context) { + ctx.on('tools/pre-execute', async (exec, next): Promise => { + if (!(await isAllowed(exec))) { + return { kind: 'deny', reason: 'Denied by policy.' } + } + return next() + }) +} +``` + +这个 waterfall(瀑布式事件)是可重排的策略层。当不变式需要单调的最终拒绝时使用 `ctx.tools.guard()`;当插件需要包裹实际分发生命周期时(超时/重试/指标;仅 `exec.signal` 可替换)使用 `tools/execute`;显式结果变换使用 `tools/post-execute`;对不可变最终结果的受限观察使用 `tools/result`。选择规则见[添加工具指南](./adding-a-tool.md#execution-policy-and-observation)。 + +## UI 插件 + +UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。 + +```ts +import type { Context } from 'cordis' +import { AgentId } from '@deepseek-ai/dsh-agent' + +declare function render(text: string): void +declare function onUserInput(handler: (text: string) => void): void + +export const name = 'my-ui' +export const inject = ['agents'] + +export function apply(ctx: Context) { + ctx.on('session/event', (_session, event) => { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + render(event.data.chunk.text) + } + }) + onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) +} +``` + +## 客户端驱动插件(外部协议桥接) + +*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。 + +`packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。 + +```ts +import type { Context } from 'cordis' + +export const name = 'my-protocol-bridge' +export const inject = ['agents', 'sessions', 'sessionPersistence'] + +export function apply(ctx: Context) { + // Stream every logged assistant text/reasoning delta out to the client. + ctx.on('session/event', (_session, event) => { + if (event.type === 'assistant/chunk') { + const chunk = event.data.chunk + if (chunk.type === 'text-delta') { + // sendToClient({ kind: 'message_chunk', text: chunk.text }) + } + } + }) + // Inbound "prompt": create/resume an agent and feed it; settle on turn end. + // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). +} +``` + +## 可运行的组装示例 + +三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent),ACP 演示加载 [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent),两个 app 包通过 [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle 共享主干。 + +## 功能→机制映射 + +每个产品功能都映射到一个文档化扩展 seam 上的监听器——微内核声明由此可验证([微内核 RFC](../rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md))。没有任何一行修改循环本身。 + +`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。 + +| 产品功能 | 插件机制 | +|---|---| +| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 | +| `/goal` | 通过 `agent/turn-continuation` 强制继续 + `steer()` 提醒 | +| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | +| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | +| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + 串行 `agent/pre-step` seam 上的后端(`dsh-compact-basic`);自动 = 每步之前的 token 压力检查;手动触发调用同一个 `ctx.compact` 例程([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | +| AGENTS.md(根目录) | 一个读取该文件的 section provider | +| AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | +| 内置工具 | `ctx.tools.register()`;schema 自动流入装配——`dsh-tool-*` 系列(bash、fs、web、subagent、todo)是已交付的示例 | +| ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 | +| 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 | +| 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` | +| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 | +| 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` | +| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 | +| Plan mode | `tools/pre-execute`(拒绝写操作)+ 通过 `ctx.systemPrompt.section()` 或 `agent.inject()` 注入模式提示词段(model-visible ⟺ logged:`agent/request` 仅塑形调用配置) | +| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 | +| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` | +| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 | +| 记忆 | section provider + 工具 | +| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 | +| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` | +| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` | +| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) | +| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 | diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml new file mode 100644 index 0000000000..d75b8cad3e --- /dev/null +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.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 +responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47 +responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926 diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md index 4eb7dc482d..3fb7eb943e 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -1,6 +1,8 @@ # Responding to review across a stacked PR chain -A wave of review comments lands across several PRs in a dependent stack (`A ← B ← C …`). This is the discipline for resolving it without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch. +English | [中文](responding-to-pr-review-on-a-stack.zh.md) + +Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch. ## Ground rules @@ -9,7 +11,7 @@ A wave of review comments lands across several PRs in a dependent stack (`A ← 3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer. 4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work. -## Working the wave +## Resolve comments through the stack 1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause. 2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md new file mode 100644 index 0000000000..d96323b853 --- /dev/null +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md @@ -0,0 +1,26 @@ +# 在堆叠 PR 链中回应评审意见 + +[English](responding-to-pr-review-on-a-stack.md) | 中文 + +评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。 + +## 基本规则 + +1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。 +2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge `,产生一个新的 merge commit)。绝不对已推送的分支做 rebase/amend/force-push:改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史。 +3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并到 `C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。 +4. **每个评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送、尚未评审的工作才可以 amend。 + +## 沿堆叠解决评审意见 + +1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。 +2. 将每个被接受的发现映射到其发起 PR,在那里修复,然后按顺序沿链向下合并。 +3. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。 +4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit。 +5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)。 + +## 验证 + +- 每个已修复的 PR 显示一个新 commit(PR 时间线中没有 force-push 图标)。 +- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。 +- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index aa87ea66d7..ebd7b75bc0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -245,7 +245,7 @@ Creation announcement during session publication. A synchronous throw vetoes and 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -255,7 +255,7 @@ Emitted once when an announced session leaves the store, including publication r 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -267,7 +267,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:66`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -277,29 +277,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) - -## `skill/*` - -### `skill/provider-added` — emit - -A skill provider became resolvable in the `ctx.skills` registry. Consumers can observe this instead of depending on Cordis plugin load order, which is concurrent for sibling plugins. - -```ts cordis-catalog -'skill/provider-added'(provider: SkillProvider): void -``` - -Source: [`packages/skill/skill/src/index.ts:131`](../../packages/skill/skill/src/index.ts) - -### `skill/provider-removed` — emit - -A skill provider left the registry because its plugin fiber was disposed. - -```ts cordis-catalog -'skill/provider-removed'(name: string): void -``` - -Source: [`packages/skill/skill/src/index.ts:137`](../../packages/skill/skill/src/index.ts) +Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) ## `subagent/*` @@ -311,7 +289,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -341,7 +319,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0544b1ff4..f5dc3b7a89 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -169,7 +169,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` @@ -200,7 +200,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:560`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:564`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -213,7 +213,7 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Source: [`packages/skill/skill/src/index.ts:158`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -226,7 +226,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:125`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` @@ -256,7 +256,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:364`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -275,7 +275,7 @@ The web access service. Registered as `ctx.web` (one instance per context). Selection semantics (resolved at execution time, never order-dependent): -- A configured id that is registered and `status().available` → that provider. +- A configured id that is registered and `available()` → that provider. - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. - A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. - No id configured, exactly one registered usable provider → that provider. @@ -285,11 +285,11 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void -async search(request: WebSearchRequest, exec?: WebExecContext): Promise -async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +async search(request: WebSearchRequest, signal?: AbortSignal): Promise +async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/web/web/src/index.ts:78`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts) ## `ctx.workflows` — `WorkflowService` (abstract seam) diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index 5b14da901c..d8e92dcb67 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -31,6 +31,10 @@ Cooperative listeners usually mutate a shared request or decision object and the For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate. +## Loader Configuration + +`@cordisjs/plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. + ## Practical Rules Encapsulate behavior into plugins: a tool pipeline event belongs to `ctx.tools`, model streaming belongs to `ctx.llm`, and live agent coordination belongs to `ctx.agents`. Prefer events for interception and policy; prefer service methods for direct capability calls. diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index d0161372d7..a5efe1a504 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -202,7 +202,6 @@ A long-running command started with `start()` is tracked as a `BashTask`. `BashT ```ts type-equiv interface BashTask { readonly id: BashTaskId - readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ exitCode: number | null diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..8926a8a998 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,7 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | -| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` | +| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 08c289e5e5..132d22d521 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -137,7 +137,6 @@ type ToolGuard = (execution: Readonly) => string | undefined ```ts type-equiv interface ToolExecutionResult { - callId: CallId content: ContentBlock[] isError: boolean /** @@ -167,6 +166,8 @@ interface ToolExecutionResult { } ``` +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 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. 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`: diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 9424b9ed70..9d79cd96c8 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -25,8 +25,6 @@ interface WebSearchRequest { ```ts type-equiv interface WebSearchResult { - readonly providerId: string - readonly query: string readonly content?: string readonly sources: readonly WebSearchSource[] readonly truncated: boolean @@ -49,7 +47,6 @@ interface WebSearchSource { ```ts type-equiv interface WebFetchRequest { readonly url: string - readonly timeoutMs?: number } ``` @@ -57,7 +54,6 @@ HTTP status is part of the fetched resource state, not automatically a failure: ```ts type-equiv interface WebFetchResult { - readonly providerId: string readonly url: string readonly statusCode: number readonly body: WebFetchBody @@ -73,15 +69,9 @@ type WebFetchBody = | { readonly kind: 'text'; readonly content: string } ``` -## Provider status +## Provider availability -A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message. - -```ts type-equiv -type WebProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } -``` +A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d1a097fe24..ec2da1408d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -3,20 +3,20 @@ # Event Producer And Consumer Matrix -This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment. +This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment. | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -25,16 +25,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:66`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | @@ -55,4 +53,4 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | -Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`. +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 69f0e170e0..d14d0f2fd8 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -40,7 +40,7 @@ | Cordis | Cordis | | | | | dispose | dispose | dispose(资源释放) | | | | doc-sync | doc-sync | doc-sync(文档同步门禁) | | | -| fiber | fiber | fiber(插件运行时) | | | +| fiber | fiber | | | | | fixture | fixture | fixture(测试前置数据) | | | | fork | fork | | | | | Function Calling | Function Calling | Function Calling(函数调用) | | | @@ -51,7 +51,6 @@ | loader | loader | | | | | manifest | manifest | manifest(元数据清单) | | | | monorepo | monorepo | | | | -| package | package | | | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) | | schema | schema | | | | | schema DSL | schema DSL | | | | | seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | @@ -125,6 +124,7 @@ | module | 模块 | | | | | orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 | | orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 | +| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 | | pairing | 配对 | | | | | peer dependency | 对等依赖 | 对等依赖(peer dependency) | | | | permission | 权限 | | | | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index a300ef64f4..60a023979a 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -79,6 +79,11 @@ You are a senior technical translator specializing in LLM and agent development #### When translating into English - Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text. +- Convert enumeration commas (、) to English commas; convert 「」 quotes to English double quotes. +- Render the terminology table's English column the same way the Chinese column binds the other direction: listed terms use exactly the table's English form; first-occurrence glosses do not carry over (English prose never glosses an English term with Chinese). +- Chinese topic-comment sentences and dropped subjects become explicit English subjects; prefer concise declaratives over nominalizations. +- Do not transliterate Chinese engineering idioms literally: render the underlying concept (误报 → false positive, 执行红线 → enforcement frontier), consulting the terminology table first. +- Keep the register of institutional developer documentation: contractions are acceptable, marketing language and hedging (very, quite, simply) are not. ## Terminology diff --git a/docs/module-graph.md b/docs/module-graph.md index 7d6bf65c21..7ceab41537 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -89,6 +89,7 @@ flowchart TD pkg_acp_snapshot["acp-snapshot"] pkg_invariants["invariants"] pkg_llm_replay["llm-replay"] + pkg_loader_smoke["loader-smoke"] pkg_subagent_mock["subagent-mock"] end subgraph group_ui["packages/ui"] @@ -98,6 +99,7 @@ flowchart TD pkg_jsonrpc["jsonrpc"] pkg_jsonrpc_agent["jsonrpc-agent"] pkg_permission["permission"] + pkg_stdio["stdio"] pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] @@ -107,6 +109,9 @@ flowchart TD pkg_code_runtime["code-runtime"] pkg_code_runtime_worker["code-runtime-worker"] end + subgraph group_context["packages/context"] + pkg_time_context["time-context"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -183,6 +188,8 @@ flowchart TD pkg_user_approval --> pkg_system_prompt pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_llm + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_system_prompt pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm @@ -200,6 +207,10 @@ flowchart TD pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_stdio --> pkg_agent + pkg_stdio --> pkg_llm + pkg_stdio --> pkg_session + pkg_stdio --> pkg_user_interaction pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -328,6 +339,7 @@ flowchart TD pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_session pkg_stdio_agent --> pkg_session_persistence_jsonl + pkg_stdio_agent --> pkg_stdio pkg_stdio_agent --> pkg_tool_ask_user pkg_stdio_agent --> pkg_tools pkg_stdio_agent --> pkg_user_interaction @@ -341,6 +353,7 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | — | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | +| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | | [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | @@ -375,10 +388,12 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -404,4 +419,4 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md new file mode 100644 index 0000000000..191c13459c --- /dev/null +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -0,0 +1,45 @@ +# Post-mortem 0002: Filesystem snapshot tools were permanently disabled + +Status: resolved + +## Executive summary + +The ACP example attempted to enable filesystem plugins conditionally with `disabled: !!js ...`, but Cordis evaluates JavaScript expressions only inside plugin `config`. The raw expression object was truthy, so the filesystem stack was always disabled. Snapshot refresh then accepted `UNKNOWN_TOOL` results as new goldens. The fix uses an explicit filesystem overlay and adds static-config and snapshot-result guards. + +## Summary + +The default ACP composition is intentionally bash-only because its sandbox cannot confine in-process filesystem providers. Filesystem snapshot scenarios still need `read`, `write`, and `edit`, so their plugins were placed in the default `cordis.yml` with a `disabled` expression intended to enable them only for full-access launches and snapshots. + +Cordis Include parsed each `!!js` scalar into an expression object. The Loader recursively interpolated the plugin's `config`, but consumed entry metadata such as `disabled` directly. Every filesystem entry therefore saw a truthy object and remained disabled in every mode. + +## Impact + +Seven filesystem scenarios and the mixed workspace-edit scenario called tools that were absent from the registry. Their structured session logs carried `ToolNotFoundError` with code `UNKNOWN_TOOL`, while stdout rendered generic failed tool cards. The snapshot suite passed because both surfaces matched the refreshed fixtures; it proved deterministic replay of the regression rather than successful filesystem behavior. + +The live confined default did not gain unintended filesystem access. A naive interpolation fix would have created that risk: permission presets update bash sandbox and approval state at runtime, but cannot mount, unmount, or confine the filesystem stack. + +## Timeline + +- PR #261 consolidated ACP compositions and refreshed the filesystem snapshots while introducing conditional filesystem entries. +- All unit, coverage, snapshot, documentation, build, and hygiene checks passed. +- Review of the refreshed filesystem goldens found generic failed cards and structured `UNKNOWN_TOOL` results. +- A real Loader boot confirmed that every `disabled` value remained an expression object and every filesystem fiber was absent. + +## Root cause + +The implementation assumed `!!js` applied to an entire Loader entry. Its actual boundary is narrower: `Entry._resolveConfig()` interpolates only `entry.options.config`; `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic. + +The snapshot framework treated any deterministic transcript as valid behavior. Header pins verified the composed tool schemas, but the filesystem scenarios shared a pin from the default composition and therefore did not independently prove that their required tools were registered. Refresh rewrote the expected stdout and session logs before any semantic assertion rejected missing tools. + +## Guardrails added + +- Filesystem scenarios boot `fs.cordis.yml`, an explicit fixed full-access overlay with a paired replay config and its own request-header class. +- [`AGENTS.md`](../../AGENTS.md) and the [Cordis primer](../cordis-primer.md#loader-configuration) state that `!!js` is valid only under plugin `config` and conditional composition uses overlays. +- `verify-cordis-config` parses repository Cordis YAML and rejects expression nodes in Loader entry metadata, including include patches and inserted entries. +- `dsh-acp-snapshot` rejects structured `UNKNOWN_TOOL` results in fresh runs and committed session fixtures before they can become accepted goldens. + +## Lessons + +- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries. +- A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the golden. +- Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely. diff --git a/docs/postmortem/README.md b/docs/postmortem/README.md index b8a07957d9..743433a827 100644 --- a/docs/postmortem/README.md +++ b/docs/postmortem/README.md @@ -11,3 +11,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus | # | Title | |---|---| | [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` | +| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 6280b4e50f..22556c2215 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -20,8 +20,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | -| [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | -| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | ### Architecture @@ -78,6 +76,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | +| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | ### Simplification @@ -102,6 +101,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | +| [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | +| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | ### Architecture @@ -174,6 +175,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 | | [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 | | [Project canonical documentation into the website](implemented/process/2026-07-13-documentation-site-projection.md) | 2026-07-13 | +| [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 | ### Testing diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 305c092303..6ba0df41c4 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -31,7 +31,7 @@ export type SurfaceOp = ### SurfaceManager: delta-based, not full rebuild -A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding). +A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial delta folded on first access. Delta processing is O(1) when no new events and O(new events) when new events arrive. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 40a95a7100..2909ccd0e6 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -64,7 +64,7 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. @@ -77,52 +77,42 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end ```ts interface WebSearchProvider { readonly id: string - status(): WebProviderStatus - search(request: WebSearchRequest, exec?: WebExecContext): Promise + available(): boolean + search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string - status(): WebProviderStatus - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise + available(): boolean + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } interface WebService { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void - search(request: WebSearchRequest, exec?: WebExecContext): Promise - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise -} - -interface WebExecContext { - readonly signal?: AbortSignal + search(request: WebSearchRequest, signal?: AbortSignal): Promise + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } ``` -`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`. +The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`. Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber. -## Provider status and selection +## Provider availability and selection -Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. +Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls. -`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state. +`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state. -`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner. - -```ts -type WebProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } -``` +The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner. Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. | Situation | Execution behavior | |---|---| -| A configured provider id is registered and `status().available === true` | runs that provider | +| A configured provider id is registered and `available() === true` | runs that provider | | A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` | | A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider | @@ -184,8 +174,6 @@ interface WebSearchRequest { } interface WebSearchResult { - readonly providerId: string - readonly query: string readonly content?: string readonly sources: readonly WebSearchSource[] readonly truncated: boolean @@ -212,20 +200,17 @@ The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `l The seam request stays smaller than OpenCode's model-facing tool: - `url`: required HTTP(S) URL. -- `timeoutMs`: optional positive number capped by the provider. -The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional. +The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional. HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure. ```ts interface WebFetchRequest { readonly url: string - readonly timeoutMs?: number } interface WebFetchResult { - readonly providerId: string readonly url: string readonly statusCode: number readonly body: WebFetchBody @@ -257,11 +242,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi `dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`. -`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. +`dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically. -Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. +Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 4f31c48623..7bd4e2462d 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -57,9 +57,8 @@ Signal replacement is by **in-place mutation of `exec.signal`**, not by passing `timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is: ```ts ignore-check -function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { +function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { - callId, content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, @@ -75,7 +74,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin `web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`. -`dsh-web-fetch-local` keeps a provider-level timeout (`timeoutMs`/`maxTimeoutMs`) as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls. +`dsh-web-fetch-local` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls. `bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index f09ab214f5..2c3126793f 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -328,7 +328,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt ### Generated artifacts keep public contracts aligned -The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, and type-equivalence blocks are generated or freshness-gated from source. `verify-scoped-dispatch` keeps the declared scoped-event set aligned with runtime invariant coverage. +The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, type-equivalence blocks, and scoped-event resolver map are generated or freshness-gated from source. The [TypeScript semantic-gates RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) owns Program construction, semantic event discovery, and resolver-generation rules. Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. diff --git a/docs/rfc/implemented/feature/2026-07-05-skill-system.md b/docs/rfc/implemented/feature/2026-07-05-skill-system.md index b3007529cd..c29140a854 100644 --- a/docs/rfc/implemented/feature/2026-07-05-skill-system.md +++ b/docs/rfc/implemented/feature/2026-07-05-skill-system.md @@ -12,7 +12,7 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth `@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the session-prefix catalog and model-facing loader tool. `dsh-agent-core` loads the registry, local provider, and consumer by default so stdio and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners. -Provider plugins register synchronously during `apply()`. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session prefix. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. +Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session prefix. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name. The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml new file mode 100644 index 0000000000..e70f8059f5 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.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-14-time-context-plugin.md: 13e0eff4b9d286ee562d7a0a2c0a3a659126ba3b +2026-07-14-time-context-plugin.zh.md: 5ee50a4d49eb9a7e00a09f70b436e15d72612b1f diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md new file mode 100644 index 0000000000..13e0eff4b9 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md @@ -0,0 +1,57 @@ +# RFC: Optional time-context plugin + +Status: implemented + +English | [中文](2026-07-14-time-context-plugin.zh.md) + +## Problem + +An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message. + +Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle. + +## Decision + +`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-core` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. + +The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section. + +### Previous-message baseline + +At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`. + +The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero. + +### Refresh policy + +`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound. + +When `timeZone` is omitted, `Intl.DateTimeFormat` resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone. + +### Logging and token shape + +The loop records the temporal block through `request/header` and `request/header-delta` before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case. + +## Testing + +Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and `request/header-delta`. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. + +## Alternatives considered + +- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation. +- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock. +- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging. +- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template. +- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. +- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it. +- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either. +- **Mount the plugin in `dsh-agent-core`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. +- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. + +## Consequences + +- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session. +- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user. +- A refresh changes the request header and can add a `request/header-delta`. `refreshIntervalMs` trades freshness against durable deltas; `0` records a new value on every step whose whole-second rendering changes. +- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles. +- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract. diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md new file mode 100644 index 0000000000..5ee50a4d49 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md @@ -0,0 +1,57 @@ +# RFC:可选时间上下文插件 + +Status: implemented + +[English](2026-07-14-time-context-plugin.md) | 中文 + +## 问题 + +如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 + +提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 + +## 决策 + +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-core` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 + +该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 + +### 上一条消息基线 + +在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。 + +基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。 + +### 刷新策略 + +`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。 + +省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。 + +### 日志与 token 形态 + +agent loop(智能体循环)会在发送前通过 `request/header` 和 `request/header-delta` 记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。 + +## 测试 + +单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和 `request/header-delta`。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 + +## 考虑过的替代方案 + +- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。 +- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。 +- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。 +- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。 +- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 +- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 +- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 +- **在 `dsh-agent-core` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 +- **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 + +## 后果 + +- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。 +- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。 +- 刷新会改变请求头,并可能新增 `request/header-delta`。`refreshIntervalMs` 用新鲜度换取持久增量记录的数量;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。 +- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。 +- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。 diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml new file mode 100644 index 0000000000..eb411f1158 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.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-14-typescript-program-backed-semantic-gates.md: 3e7a76e86d83080ae1a4f91ca97cc9749c90ef29 +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 0a13452012e7f6cbd3ad7994845ba1985355c089 diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md new file mode 100644 index 0000000000..3e7a76e86d --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md @@ -0,0 +1,63 @@ +# RFC: TypeScript Program-backed semantic gates + +Status: implemented + +English | [中文](2026-07-14-typescript-program-backed-semantic-gates.zh.md) + +## Problem + +Repository gates sometimes need facts that TypeScript syntax does not carry by itself: whether a receiver is a Cordis `Context`, which concrete event names reach a forwarding helper, and whether declaration merging changed an event signature. + +The existing gates use TypeScript's single-file syntax model and maintain these facts through naming conventions, handwritten tables, and JSDoc. + +The repository needs one semantic source of truth without introducing runtime package cycles, broad fallback heuristics, or machine-readable annotations that restate information already available to TypeScript. + +## Decision + +Repository gates can combine project-wide type information through `ts.Program` and use `TypeChecker` to extract **strongly typed** facts, reducing their reliance on naming conventions, handwritten tables, and JSDoc metadata. + +The repository applies this model to two gates. + +### One project model expands the root solution + +[`TypeScriptProject`](../../../../scripts/ts-project.ts) parses the root `tsconfig.json`, recursively expands every project reference, and combines the referenced source roots into one no-emit semantic program. A normal program created from the solution config can redirect referenced projects to built declarations; explicit expansion keeps the package `src` files available for AST traversal and symbol identity. + +The wrapper owns config diagnostics, semantic compiler options, repository-relative paths, source lookup, and the shared checker. Individual gates do not glob package sources or construct partial programs independently. + +### A. Event relations follow receiver and value types + +[`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) classifies calls by assignability to the repository's actual `Context`, `AgentEventDispatch`, and Cordis `EventsService` types. Variable names and property spellings do not determine whether a call is an event operation. + +Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value. + +Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships. + +### B. Scoped-event routing generates one typed resolver map + +[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) scans real `scopeTarget(base, key)` calls to establish the routing-key type for each scoped base. It then finds Cordis `Events` members with `this: Scoped` and searches every payload parameter plus one public property level for a type identical to that key after removing `null` and `undefined`. + +Exactly one match generates a resolver. Multiple matches are ambiguous and fail. Zero matches require `@dshScopeScan unsupported`, which is reserved for events whose routing key intentionally stays outside the payload, such as owner-keyed session events and parent-keyed subagent lifecycle events. The annotation records an unsupported scan; it does not encode an event name, parameter index, property path, or replacement type. + +The committed [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) imports every scoped-event owner for its type-side `Events` contributions. Each generated lambda accepts `Parameters`, and the complete object satisfies a `Record` over the derived `ScopedEventName` union. Ordinary TypeScript compilation therefore checks event existence, parameter position, property access, and scoped-event completeness. The only cast adapts Cordis's runtime `unknown[]` dispatch boundary to the already type-checked resolver. + +The invariants plugin consumes this generated runtime map instead of maintaining its own table. Additional event-owner packages are dev dependencies and project references of `dsh-invariants`, not peer dependencies, so the compile-time aggregation does not expand the plugin's runtime closure. + +### Semantic gaps fail explicitly + +The generators reject missing declarations, config diagnostics, widened or generic event names, inconsistent routing-key types, ambiguous payload matches, unnecessary unsupported annotations, and stale generated output. Recovery through local helper call sites is deliberately narrow: exported or unresolved dataflow requires a new semantic rule rather than a package-specific override. + +## Verification + +`verify-doc-graphs` freshness-checks semantic producer/listener discovery, and `verify-scoped-events` freshness-checks the generated resolver map. The root TypeScript build compiles the resolver against merged `Events`; workspace constraints and runtime-closure checks ensure its type-only aggregation does not become a deployment dependency. + +## Alternatives considered + +- **Keep syntax-only scans with receiver allowlists and manual overrides.** This is simple per exception but makes renames and new helper shapes update a second representation. Completeness can detect a missing producer, but it cannot prove that the override still describes the source. + +## Consequences + +- Event relation generation follows semantic receiver identity and closed event values instead of local naming conventions. +- Scoped-event membership, subject extraction, and runtime invariant coverage come from event declarations and real dispatch contracts rather than handwritten tables. +- Refactors that change event names, parameter positions, subject properties, or routing-key types fail generation or compilation at the owning contract. +- Building a flattened Program costs more startup time and memory than parsing isolated files, and semantic gates depend on a valid root project graph. +- Generated TypeScript remains committed source: changes to event owners or dispatch shapes must regenerate it and the affected documentation. diff --git a/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md new file mode 100644 index 0000000000..0a13452012 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md @@ -0,0 +1,63 @@ +# RFC: 基于 TypeScript Program 的语义门禁 + +Status: implemented + +[English](2026-07-14-typescript-program-backed-semantic-gates.md) | 中文 + +## 问题 + +仓库门禁有时需要判断 TypeScript 语法本身不携带的事实:接收者是否为 Cordis `Context`、哪些具体事件名会进入转发辅助函数、声明合并是否改变了事件签名。 + +当前的门禁基于 TypeScript 单文件语法解析能力,使用命名约定、手写的表格、JSDoc 等方式来维护这类信息。 + +仓库需要一个语义真源,同时不能引入运行时包(package)之间的循环依赖、宽泛的兜底启发式逻辑,或重复描述 TypeScript 已有信息的机器可读标注。 + +## 决策 + +仓库可以通过项目级类型信息 `ts.Program` 进行跨文件项目类型联合计算,并通过 `TypeChecker` 来提取 **强类型** 信息,用以缓解原有命名约定、手写表格、JSDoc 标注等形式。 + +当前已完成 A / B 两个门禁的语义化改造。 + +### 一个项目模型展开根项目配置 + +[`TypeScriptProject`](../../../../scripts/ts-project.ts) 解析根 `tsconfig.json`,递归展开每个项目引用,并将各引用项目的源码根合并为一个不输出文件的语义 Program。直接从根项目配置创建普通 Program 时,TypeScript 可能将引用项目重定向到构建后的声明文件;显式展开可以让门禁继续遍历各包的 `src` 文件,并使用真实符号标识。 + +该封装统一负责配置诊断、语义编译选项、仓库相对路径、源码查找和共享 TypeChecker。各门禁不再自行按文件通配模式扫描包源码,也不再分别构建不完整的 Program。 + +### A. 事件关系由接收者类型和值类型决定 + +[`gen-doc-graphs`](../../../../scripts/gen-doc-graphs.ts) 根据调用接收者与仓库中真实 `Context`、`AgentEventDispatch` 和 Cordis `EventsService` 类型之间的可赋值关系进行分类。变量名和属性拼写不再决定某次调用是否属于事件操作。 + +Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。 + +每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。 + +### B. 带作用域的事件路由生成一份强类型解析函数表 + +[`gen-scoped-events`](../../../../scripts/gen-scoped-events.ts) 扫描真实的 `scopeTarget(base, key)` 调用,为每种 scoped 基础对象确定路由键类型。随后,它查找带有 `this: Scoped` 的 Cordis `Events` 成员,并在每个事件参数及其一层公开属性中搜索类型;移除 `null` 和 `undefined` 后,候选类型必须与路由键类型完全相同。 + +恰好一个匹配项会生成解析函数。存在多个匹配项时,含义不明确,生成器会失败。没有匹配项时,事件必须标记 `@dshScopeScan unsupported`;该标记只用于路由键有意留在事件参数之外的情况,例如按所属 agent(智能体)路由的会话事件和按父 agent 路由的 subagent 生命周期事件。此标记只表示扫描不受支持,不编码事件名、参数下标、属性路径或替代类型。 + +仓库提交的 [`scoped-events.generated.ts`](../../../../packages/support/invariants/src/scoped-events.generated.ts) 会导入每个带作用域的事件声明方,使它们从类型侧合并进 `Events`。每个生成函数都接收 `Parameters`,完整对象则满足基于 `ScopedEventName` 联合类型派生出的 `Record`。因此,常规 TypeScript 编译会检查事件是否存在、参数位置、属性访问和带作用域的事件集合完整性。唯一的类型断言只负责将 Cordis 运行时的 `unknown[]` dispatch 边界适配到已经通过类型检查的解析函数。 + +不变式插件消费这份生成的运行时表,不再维护自己的事件表。新增的事件声明方包只作为 `dsh-invariants` 的开发依赖和项目引用存在,不进入对等依赖,因此编译期聚合不会扩大插件的运行时依赖闭包。 + +### 语义缺口必须显式失败 + +遇到声明缺失、配置诊断、事件名被拓宽或保持泛型、路由键类型不一致、事件参数匹配不唯一、不必要的 unsupported 标记,或生成产物陈旧时,生成器都会拒绝继续。通过本地辅助函数调用点恢复信息的能力被刻意限制在窄范围内:如果数据流经过导出或无法解析的边界,应新增通用语义规则,而不是添加特定包的覆盖项。 + +## 验证 + +`verify-doc-graphs` 对语义生产方/监听方扫描执行新鲜度检查,`verify-scoped-events` 对生成的解析函数表执行新鲜度检查。根 TypeScript 构建会将解析函数与合并后的 `Events` 一起编译;workspace 约束和运行时依赖闭包检查则确保仅参与类型聚合的依赖不会变成部署依赖。 + +## 考虑过的替代方案 + +- **保留语法扫描、接收者白名单和手写覆盖项。** 每个例外都容易单独处理,但重命名和新增辅助函数形态时还必须更新第二份表示。完整性检查能够发现生产方缺失,却无法证明覆盖项仍与源码一致。 + +## 后果 + +- 事件关系生成依据语义接收者身份和封闭事件值,不再依赖局部命名约定; +- 带作用域的事件成员关系、主体提取和运行时不变式覆盖来自事件声明与真实 dispatch 契约,不再来自手写表; +- 修改事件名、参数位置、主体属性或路由键类型时,会在其所属契约处触发生成或编译失败; +- 构建扁平化 Program 比解析孤立文件消耗更多启动时间和内存,语义门禁也依赖有效的根项目图; +- 生成的 TypeScript 仍属于提交到仓库的源码:事件声明方或 dispatch 形态发生变化后,必须重新生成该文件和受影响的文档。 diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 2c7626619b..634e8ac6ca 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam. Per-file tests cover EOF, rendering, disposal, and piped-versus-TTY behavior without replacing process globals. It retains the named Cordis plugin export shape consumed by the app; an `unwrapExports` assertion and keyless Loader smokes guard both the package and composed entry paths. +The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md similarity index 52% rename from docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md rename to docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md index 9a0d9d2cb7..0907a63417 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md +++ b/docs/rfc/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md @@ -1,6 +1,6 @@ # RFC: Drop unconsumed skill provider events -Status: proposed +Status: implemented ## Problem @@ -10,23 +10,18 @@ Skill discovery reads the current provider map on demand, provider registration `tools/change` and `system-prompt/change` are explicitly outside this proposal. Existing simplification decisions retain them as intentional observation points for live tool and prompt UIs, and self-referential mounted plugins already use `tools/change`. This proposal also leaves `subagent/provider-added`/`removed` unchanged because `tool-subagent` has a production lifecycle consumer. -## Proposal +## Decision -Delete the two skill-provider declarations and every emit path, rollback-order branch, test, and generated catalog/matrix row that exists only for them. Remove the corresponding skill-registry README/JSDoc contract. Where tests used an event to observe cleanup, assert provider lookup or collected output instead. +The skill registry declares and emits no provider-membership events. Provider registration and disposal remain direct effect-owned state changes that synchronously invalidate completed catalogs; lookup and discovery read the current provider map on demand. Tests observe cleanup through provider lookup and collected output rather than lifecycle notifications. -Amend the skill-system RFC and package documentation so provider registration is described as direct effect-owned state with cache invalidation, not as a lifecycle notification contract. +The generated event catalog, API catalog, and producer/consumer matrix omit the deleted notifications. The skill-system RFC and package documentation describe registration through its direct effect-owned state and cache-invalidation contract. ## Alternatives considered **Keep skill-provider notifications for future plugins.** A third-party plugin could observe provider availability, but direct provider registration and on-demand lookup are the extension contract; no current consumer needs a push signal. If a future sibling-load race appears, it can introduce a notification with the identity and readiness semantics that consumer requires, as the subagent registry did. -## Acceptance criteria +## Consequences -- The generated event matrix contains no row for `skill/provider-added` or `skill/provider-removed`. -- Skill discovery, direct runtime registration, provider effect rollback/disposal, cache invalidation, and registry lookup cleanup behave unchanged; listener-triggered rollback disappears with the events. -- `tools/change`, `system-prompt/change`, and the real subagent provider lifecycle consumer remain documented and covered. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. +The generated event matrix contains no row for `skill/provider-added` or `skill/provider-removed`. Skill discovery, direct runtime registration, provider effect rollback/disposal, cache invalidation, and registry lookup cleanup remain; listener-triggered rollback disappears with the events. `tools/change`, `system-prompt/change`, and the consumed subagent provider lifecycle events are unchanged. -## Risks - -This removes pre-release skill-provider observation points while retaining both ways third-party plugins contribute skills: direct runtime registration and provider registration. A future consumer that needs live provider availability must add a purpose-built notification rather than relying on these generic events. +Pre-release consumers lose skill-provider observation points while retaining both ways to contribute skills: direct runtime registration and provider registration. A future consumer that needs live provider availability must add a purpose-built notification with the identity and readiness semantics it actually requires. diff --git a/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md similarity index 61% rename from docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md rename to docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md index 1a8495426e..8ece4214b7 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md +++ b/docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md @@ -1,6 +1,6 @@ # RFC: Prune unused web seam fields -Status: proposed +Status: implemented ## Problem @@ -8,23 +8,18 @@ The web capability carries request/result/status values that every shipped imple `WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists. -## Proposal +## Decision -Remove the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Shrink provider status to availability alone, preferably a boolean-returning method if that produces the clearest seam. Remove per-request fetch timeout, `maxTimeoutMs`, and their clamp/validation branches while retaining the provider's configurable default timeout and tool-level deadline. Replace `WebExecContext` with a direct optional `AbortSignal` parameter. +The web seam omits the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Providers expose availability as a boolean-returning method. Fetch requests have no per-request timeout or `maxTimeoutMs` clamp; the local provider retains its configurable default timeout and the tool retains its own deadline. Provider methods receive a direct optional `AbortSignal` instead of a one-field `WebExecContext` wrapper. -Update all web implementations, the model-facing tool, package READMEs/JSDoc, type-equivalence records, and tests. Keep the interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and all safety limits. +All web implementations and the model-facing tool use the smaller contract. The interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and safety limits remain. ## Alternatives considered **Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object. -## Acceptance criteria +## Consequences -- Every retained web request/result/status field has a production reader or is required to execute the provider request. -- Tool-visible search/fetch output, provider fallback, abort behavior, configured timeout backstop, truncation, and citations remain covered. -- No `maxTimeoutMs`, request-timeout precedence branch, or one-field execution-context wrapper remains. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. - -## Risks +Every retained web request/result field is consumed by production code or required to execute the provider request. Tool-visible search/fetch output, provider fallback, abort behavior, the configured timeout backstop, truncation, and citations remain covered without a request-timeout precedence branch or execution-context wrapper. Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound. diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md index 00a60ad4bc..42c67320f6 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md @@ -8,11 +8,11 @@ An ACP snapshot suite needs to prove the exact composed system prompt and tool-s ## Decision -Exactly one scenario per header-composition class is flagged `pinsHeader`. Its directory splits the pin by review format: `system-prompt.golden.md` contains the normalized composed prompt as ordinary Markdown, while `session.jsonl` keeps the full tool-schema list, config, and reason but stores `header.system` as `"{{system}}"`. Every other JSONL stores both the system prompt and tool list as `"{{system}}"` / `"{{tools}}"`. The pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per class. +Exactly one scenario per header-composition class is flagged `pinsHeader`. Its directory splits the pin by review format: `system-prompt.golden.md` contains the normalized composed prompt as ordinary Markdown, `tool-schemas.golden.json` contains the complete initial schemas and later schema edits as structured JSON, and `session.jsonl` retains config, reason, and any model-visible prefix while storing `header.system` and `header.tools` as `"{{system}}"` / `"{{tools}}"`. Every other JSONL uses the same prompt and tool tokens and also tokenizes session-prefix content. The pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per class. -The pure `scrubSystemPrompts` normalizer applies to every stored session fixture and tokenizes both an initial header's prompt and a header delta's inserted prompt lines. `scrubRequestHeaders` additionally tokenizes tool schemas and session-prefix content for non-pinning scenarios while retaining structural facts: system-delta positions and arity, added/removed/changed tool names, prefix message count, field presence, config, and reason. Record and refresh write-back apply the appropriate scrub before writing JSONL and regenerate the Markdown prompt from the normalized live header, so neither path can reintroduce prompt text into JSONL or leave the readable snapshot stale. +The pure `scrubSystemPrompts` and `scrubToolSchemas` normalizers apply to every stored session fixture and independently tokenize initial-header content plus header-delta bulk. `scrubRequestHeaders` also tokenizes session-prefix content for non-pinning scenarios while retaining structural facts: system-delta positions and arity, added/removed/changed tool names, prefix message count, field presence, config, and reason. Record and refresh write-back apply the appropriate scrub before writing JSONL and regenerate both sidecars from the normalized live header and deltas, so neither path can reintroduce prompt/schema bulk into JSONL or leave a review artifact stale. -Guards make the split self-enforcing. On disk, every `session*.jsonl` is a fixed point of `scrubSystemPrompts`, only non-pinning fixtures are fixed points of the full header scrub, `system-prompt.golden.md` exists exactly beside pinning fixtures, and each class has one pin. Live, every `request/header` produced by a parent, spawn child, fork child, initial request, or resume must match both halves of its class's pin after volatile-value normalization. A header without a string prompt or any `request/header-delta` fails loud because the two static pin artifacts cannot represent it. +Guards make the split self-enforcing. On disk, every `session*.jsonl` is a fixed point of both prompt and schema scrubbers, only non-pinning fixtures must be fixed points of the full header scrub, both sidecars exist exactly beside pinning fixtures in canonical newline-terminated formats, and each class has one pin. Live, every `request/header` produced by a parent, spawn child, fork child, initial request, or resume must match the reconstructed pin after volatile-value normalization; the pinning run's prompt and schema deltas must also match their sidecars. A header without a string prompt, without an array-valued tool list, or with an undeclared `request/header-delta` fails loud. One pin covers the whole suite because every session — parent, spawn child, fork child — composes the identical tool list and the identical prompt modulo cwd, and the uniformity guard fails the suite the moment that stops holding. If header composition ever becomes session-dependent by design (a restricted subagent toolset, say), the divergent shape gets its own pinning scenario. @@ -21,13 +21,13 @@ One pin covers the whole suite because every session — parent, spawn child, fo - **Re-record or hand-edit every fixture per change** — preserves exact headers but buries behavioral diffs under duplicated prompt and schema content. - **Scrub at compare time only, keeping fixtures raw** — lets compares pass while committed fixtures retain stale duplicate content and rewrite wholesale on the next recording. Stored tokens state honestly what each JSONL does not pin. - **Scrub everywhere, pin nowhere** — loses the only end-to-end record of the composed header as actually sent (prompt assembly, registered-tool order, full schemas). The generated tool catalog documents each tool in isolation; only a real fixture pins the composed set. -- **Keep the one full pin entirely in JSONL** — removes suite-wide duplication but leaves system-prompt changes as an escaped one-line diff entangled with the tool list. Markdown gives prompt prose its natural review format without weakening the header assertion. +- **Keep the one full pin entirely in JSONL** — removes suite-wide duplication but leaves prompt and schema changes as one escaped line. Markdown and structured JSON give each surface its natural review format without weakening the reconstructed-header assertion. - **Slim the session log itself (log a content digest, store the header elsewhere)** — violates the reconstructability contract: the product log must reproduce each request bit-for-bit ([reconstructable-requests RFC](../architecture/2026-07-05-reconstructable-requests.md)). Header bulk is a test-artifact concern, solved in test normalization; the live log is untouched. ## Verification -The suite replays every scenario against the split pins. Unit coverage exercises both scrub levels, Markdown formatting, record/refresh regeneration, normalized prompt extraction, fixed-point enforcement, required-file symmetry, header uniformity, and delta rejection. +The suite replays every scenario against the split pins. Unit coverage exercises the independent and full scrubbers, both sidecar formats, record/refresh regeneration, normalized prompt/schema extraction, fixed-point enforcement, required-file symmetry, reconstructed-header uniformity, and delta rejection. ## Consequences -A system-prompt change produces a normal line-oriented Markdown diff in one file per affected composition class; a tool-description change produces one pinned JSONL line per class; ordinary behavioral fixtures remain untouched. Session fixtures display tokens for omitted content, and the live uniformity guard makes each split pin authoritative for every session in its class. The pinning scenario carries one extra generated artifact whose terminal newline is canonicalized for repository hygiene. +A system-prompt change produces a line-oriented Markdown diff in one file per affected composition class; a tool-description change produces a structured JSON diff in one file per class; ordinary behavioral fixtures remain untouched. Session fixtures display tokens for omitted content, and the live uniformity guard makes each split pin authoritative for every session in its class. Each pinning scenario carries two generated, newline-canonicalized sidecars. diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md index f335afafe1..715ce93924 100644 --- a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -26,7 +26,7 @@ Amend the session-surface and reconstructable-request RFCs where they describe t ## Acceptance criteria -- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain, while the separate public `invalidate()` deletion stays owned by the dead-surface RFC. +- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain. - Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains. - A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths. - New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass. diff --git a/docs/testing.md b/docs/testing.md index 2a571d6015..d4acdc33c4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## Test the real entry path -- A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). +- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 045f523c5c..f74dda4687 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -13,7 +13,7 @@ Each example has both: Mock-only examples require only the keyless tier; state that exception in the test. -Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `--expose-internals` when loading HMR. +Keyless stdio smokes use `@deepseek-ai/dsh-loader-smoke` for isolation, root-tsconfig loading, subprocess lifecycle, diagnostics, EOF, and cleanup; tests supply paths, environment, input, and assertions. Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 8f611b7d67..7e796d4de7 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with `run_code` and its generated TypeScript SDK; see [Code Mode](../../packages/core/tools/README.md#code-mode). +The leaf config loads the ACP app, DeepSeek adapter, sandboxed bash, approval and permission services, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`fs.cordis.yml`](fs.cordis.yml) adds the unconfined in-process filesystem stack for its dedicated scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. See [Code Mode](../../packages/core/tools/README.md#code-mode). ## stdout is the protocol @@ -39,11 +39,11 @@ This example hosts the ACP snapshot suite. `dsh-llm-replay` reconstructs model s The default tree composes [`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/), [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/), [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/), and [`@deepseek-ai/dsh-permission`](../../packages/ui/permission/). Bash starts in `workspace-write`; a denied operation returns a structured marker, and a retry with `sandbox_permissions` plus `justification` becomes a one-shot `session/request_permission` prompt in the editor. "Allow once" runs exactly that retry under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). -- **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined file access plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. +- **One session config option is live**: a capable client shows one `Permissions` select. `workspace-write` means workspace-confined bash plus `ask`; `danger-full-access` means unconfined bash plus `never`. Switching writes one `permission/preset` event through to the sandbox-mode and approval-policy events, and `session/load` reports the resumed value. - **Every approval is one-shot**: the choices are `Allow once` and `Reject`; a dismissal, rejection, missing editor, or unavailable runner fails closed. - **The boundary is bash-only and config-fixed today**: in-process filesystem tools are omitted from the confined live default, while the sandbox workspace root remains the server's launch directory. -`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. The snapshot suite uses the same tree: snapshot mode starts at `danger-full-access` so established fixtures remain runner-independent, while the permission-switching and escalation inputs explicitly select `workspace-write` before exercising that policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. +`tests/escalation.e2e.ts` boots this default tree keyless, drives the permission select, and—with a key and usable runner—proves both approval outcomes against the filesystem. Most snapshots use that tree and start at `danger-full-access` so bash fixtures remain runner-independent; scenarios that call `read`, `write`, or `edit` use the fixed full-access fs overlay and a separate request-header pin. The permission-switching and escalation inputs select `workspace-write` before exercising the bash policy path. No fixture pins a real denial because kernel error text is backend-specific; real confinement remains covered by the sandbox packages' kernel e2e suites. ## MVP limitations diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index d5c3096666..b93b4cb1e2 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -45,12 +45,6 @@ flowchart LR cfg --> plugin_acp_tool_todo plugin_acp_repeat_tool_guard["repeat-tool-guard
@deepseek-ai/dsh-repeat-tool-guard"] cfg --> plugin_acp_repeat_tool_guard - plugin_acp_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_acp_fs_local - plugin_acp_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_acp_fs_policy - plugin_acp_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_acp_tool_fs plugin_acp_hooks_claude["hooks-claude
@deepseek-ai/dsh-hooks-claude"] cfg --> plugin_acp_hooks_claude plugin_acp_hooks_codex["hooks-codex
@deepseek-ai/dsh-hooks-codex"] @@ -74,9 +68,6 @@ flowchart LR | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | | `tool-todo` | `@deepseek-ai/dsh-tool-todo` | | `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | | `hooks-claude` | `@deepseek-ai/dsh-hooks-claude` | | `hooks-codex` | `@deepseek-ai/dsh-hooks-codex` | diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index f69770b4dd..90a2fa6a2a 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -15,6 +15,16 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 39c12805c8..201feafe41 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -95,23 +95,6 @@ - id: repeat-tool-guard name: '@deepseek-ai/dsh-repeat-tool-guard' -# Filesystem tools do not ride the bash sandbox, so the confined default omits -# them. Snapshots and explicit danger-full-access launches enable the local -# provider, policy, and model-facing tools together. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - disabled: !!js "(process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')) !== 'danger-full-access'" - # `configPath` is read once at load and resolves from the server launch cwd, not # `session/new.cwd`; one `hooks.json` therefore applies to every session and a # project-local file is not discovered. Missing config registers nothing. Hook diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml new file mode 100644 index 0000000000..53f2d677e2 --- /dev/null +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -0,0 +1,21 @@ +# Keyless filesystem snapshots apply the filesystem and replay overlays directly +# because include patches cannot target entries behind a nested include. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml new file mode 100644 index 0000000000..b60c26f96d --- /dev/null +++ b/examples/acp-agent/fs.cordis.yml @@ -0,0 +1,17 @@ +# Filesystem snapshots need the in-process local provider, policy gate, and +# model-facing tools. This explicit overlay is always full-access: the session +# permission preset controls bash only and cannot confine or unmount these plugins. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + - id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 64c55fa179..0d29f7b5ce 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -27,6 +27,7 @@ const AGENT = { const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) +const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -47,19 +48,19 @@ const SCENARIOS: Scenario[] = [ { name: 'handshake', hasModelTurn: false, recorded: false }, { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, // text-turn is the pinned-header scenario: the minimal single text turn. - // Its system-prompt.golden.md and JSONL tool list pin the composed header. + // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, - { name: 'workspace-edit', hasModelTurn: true, recorded: true }, - { name: 'fs-read', hasModelTurn: true, recorded: true }, - { name: 'fs-write', hasModelTurn: true, recorded: true }, - { name: 'fs-edit', hasModelTurn: true, recorded: true }, - { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, - { name: 'fs-read-window', hasModelTurn: true, recorded: true }, - { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, + { name: 'workspace-edit', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-read', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-write', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-edit', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-read-window', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, + { name: 'fs-policy-reject', hasModelTurn: true, recorded: true, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, // Keyless, authored (like error-finish/cancel): deterministically forcing a diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 991806d74b..d996251cd7 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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":"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":"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index a6a3371913..391d5ce2ba 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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":"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":"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index c6213cd558..1962ff8d9e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"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_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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":"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":"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"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":6,"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() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json new file mode 100644 index 0000000000..e5b5593267 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -0,0 +1,314 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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.", + "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": "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": "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} 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 a68bb37f9f..b251bacddd 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json new file mode 100644 index 0000000000..3c3e5bb0b2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json @@ -0,0 +1,261 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} 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 9367e2deb0..c49b9ff3fa 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"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":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"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"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.golden.json new file mode 100644 index 0000000000..61875ceb85 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.golden.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "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" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 723282d1a8..90f009946d 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -69,7 +69,7 @@ {"type":"assistant/chunk","seq":67,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":68,"time":1783352086059,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[69],"surfaceOp":"append"} +{"type":"tool/result","seq":70,"time":1783352086065,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"/tmp/acp-snap-cwd-0BxHdV/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[69],"surfaceOp":"append"} {"type":"step/end","seq":71,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":72,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":73,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -129,7 +129,7 @@ {"type":"assistant/chunk","seq":127,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[129],"surfaceOp":"append"} +{"type":"tool/result","seq":130,"time":1783352087476,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[129],"surfaceOp":"append"} {"type":"step/end","seq":131,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":132,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":133,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index acc193ad1e..800d7d608a 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -46,8 +46,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_S6krdIDHoSCGWv7WnXX97617","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}} {"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"}}}} @@ -66,8 +66,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}} {"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt","old_string":"DEBUG","new_string":"RELEASE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"Edit config.txt","kind":"edit","status":"in_progress","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"completed","content":[{"type":"diff","path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}],"title":"Edit config.txt"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Done"}}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 8a500475c0..802120fd9c 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -77,7 +77,7 @@ {"type":"assistant/chunk","seq":75,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":76,"time":1783611703972,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} {"type":"tool/call","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"tool/result","seq":78,"time":1783611703978,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} {"type":"step/end","seq":79,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":80,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":81,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -144,7 +144,7 @@ {"type":"assistant/chunk","seq":142,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":143,"time":1783611705573,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[144],"surfaceOp":"append"} +{"type":"tool/result","seq":145,"time":1783611705579,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[144],"surfaceOp":"append"} {"type":"step/end","seq":146,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":147,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":148,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -225,7 +225,7 @@ {"type":"assistant/chunk","seq":223,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":224,"time":1783611707097,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: unknown tool \"edit\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[225],"surfaceOp":"append"} +{"type":"tool/result","seq":226,"time":1783611707114,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[225],"surfaceOp":"append"} {"type":"step/end","seq":227,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":228,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":229,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 5aa75c026c..fd5c9f1aea 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -36,8 +36,8 @@ {"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":"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":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} {"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":" edit"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} @@ -82,8 +82,8 @@ {"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":" first"}}}} {"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_tfr0gdIclW92oIEgi7NF0891","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","title":"Read settings.txt","kind":"read","status":"in_progress","locations":[{"path":"settings.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\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":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} @@ -124,8 +124,8 @@ {"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":" work"}}}} {"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_GVknJu2tksKkP4lALCwh0926","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"edit\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"completed","content":[{"type":"diff","path":"settings.txt","oldText":"color: blue","newText":"color: green"}],"title":"Edit settings.txt"}}} {"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":" replacement"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index f22aba96b2..becc503c65 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -91,7 +91,7 @@ {"type":"assistant/chunk","seq":89,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":90,"time":1783352101348,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[91],"surfaceOp":"append"} +{"type":"tool/result","seq":92,"time":1783352101353,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"/tmp/acp-snap-cwd-N9HCkt/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false},"sourceEventSeqs":[91],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":95,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 05832400c8..ab3eb2da31 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -56,8 +56,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_GIZwZS9a7vhWTFCIc7Z35497","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"big.txt","offset":5,"limit":4}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","title":"Read big.txt (5 - 8)","kind":"read","status":"in_progress","locations":[{"path":"big.txt","line":5}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\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":" read"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index d91d10d39a..3af4b2ac61 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1783352073708,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2882,"outputTokens":75,"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],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1783352073717,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"/tmp/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":56,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":57,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 19abb7f418..fc0edbbe8b 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -29,8 +29,8 @@ {"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":"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":"tool_call","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\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":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 0233abbe39..47627ae7a5 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -65,7 +65,7 @@ {"type":"assistant/chunk","seq":63,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":64,"time":1783352093617,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1783352093624,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":68,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":69,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -114,7 +114,7 @@ {"type":"assistant/chunk","seq":112,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[114],"surfaceOp":"append"} +{"type":"tool/result","seq":115,"time":1783352094995,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[114],"surfaceOp":"append"} {"type":"step/end","seq":116,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":117,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":118,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 4801d9410d..f0ca9674cb 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -42,8 +42,8 @@ {"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":"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":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\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":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} @@ -61,8 +61,8 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}} {"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_N23EvXjDo4c8enyWpIUq4043","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt","content":"replaced"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} {"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":" has"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 6170af99a5..7e4b2dda01 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -62,7 +62,7 @@ {"type":"assistant/chunk","seq":60,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":61,"time":1783352079888,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} {"type":"tool/call","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"Error: unknown tool \"write\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":63,"time":1783352079897,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":66,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index d5b3ca5d15..3c01ab158c 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -30,8 +30,8 @@ {"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":"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":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"notes.txt","content":"hello world"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"write\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}],"title":"Write notes.txt"}}} {"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":" has"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index d040142032..eefadc2be4 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -5,7 +5,7 @@ {"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json new file mode 100644 index 0000000000..68d4b037d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json @@ -0,0 +1,245 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 9a26fa2efe..62555f1e79 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json new file mode 100644 index 0000000000..68d4b037d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json @@ -0,0 +1,245 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 0643740e4f..59e803f394 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.","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. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"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.","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."}},"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.","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."}},"required":["description","prompt"]}},{"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?, 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), `model` (override). 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."},"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"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json new file mode 100644 index 0000000000..68d4b037d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json @@ -0,0 +1,245 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index 04e3c3b59e..9a908f24a3 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -79,7 +79,7 @@ {"type":"assistant/chunk","seq":77,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":78,"time":1783352265491,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"Error: unknown tool \"read\""}],"isError":true,"error":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"tool/result","seq":80,"time":1783352265504,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"/tmp/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","seq":81,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":82,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":83,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 23f13ff3c2..5f6c7f4c52 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -55,8 +55,8 @@ {"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":" contents"}}}} {"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_OjRFB4zvxu6UALDjytZD0978","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown tool \"read\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\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":" file"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.golden.md new file mode 100644 index 0000000000..6bb634b339 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.golden.md @@ -0,0 +1,19 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use 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. + +Use 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. + +Use 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. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use 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. diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json new file mode 100644 index 0000000000..18e285888b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json @@ -0,0 +1,320 @@ +{ + "initial": [ + { + "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]`. 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 (a background task reports the same marker via bash_output once it has finished). 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; poll it with `bash_output` and stop it with `bash_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.", + "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. No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "bash_kill", + "description": "Ask the executor to kill a running background bash task by task id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "bash_output", + "description": "Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_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": "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": "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.", + "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." + } + }, + "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.", + "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." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "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?, 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), `model` (override). 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." + }, + "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" + ] + } + } + ], + "deltas": [] +} diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index 59da071c39..ac2cb9430b 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -1,92 +1,27 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Keyless Loader-path smoke for the Code Mode overlay: boot the real example through the - * `@deepseek-ai/dsh-stdio-agent` bin against `code-mode.cordis.yml` (the cordis Loader, - * `unwrapExports`, the include patches over ./cordis.yml, the worker-thread code runtime, and - * the registry in `mode: code`), then close stdin with no prompt and assert the Code Mode - * banner + a clean exit. A dummy key satisfies adapter boot, but no prompt means - * no model call; the with-key proof lives in `code-mode.e2e.ts`. + * Keyless Loader-path smoke for the Code Mode overlay: boot the real include + * tree through stdio-agent and `code-mode.cordis.yml`, then close stdin without + * a prompt and assert the banner. No model or `run_code` turn runs. */ const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig -// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside -// the repo, so point it at the repo tsconfig. -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; -// 30s still detects a wedged child. -const PROCESS_TIMEOUT_MS = 30_000 -// Leave enough room for the process-owned timeout to report captured output -// before Vitest aborts the test itself. -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function bootAndEof(): Promise<{ stdout: string; code: number }> { - workdir = await mkdtemp(join(tmpdir(), 'code-mode-smoke-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - // --expose-internals: the included cordis.yml loads the HMR plugin (mirrors demo:code-mode). - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TSX_TSCONFIG_PATH: repoTsconfig, - // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots. - // No prompt is sent, so the adapter never streams — no network call. - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - child = proc - let stdout = '' - let stderr = '' - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { stdout += chunk }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`code-mode overlay did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, code }) - else reject(new Error(`code-mode overlay exited ${code}. stderr:\n${stderr}`)) - }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - - // No prompt — just EOF, so the stdio UI exits without ever running a turn. - proc.stdin.end() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) describe('code-mode overlay keyless smoke (real code-mode.cordis.yml via the Loader)', () => { it('boots the Code Mode plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + label: 'code-mode overlay', + tempDirPrefix: 'code-mode-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + }) expect(stdout).toContain('code-mode agent ready.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index f4b86dca4a..6cfeca646e 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -1,90 +1,28 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Boots the real example through the stdio bin and `cordis.yml`, covering Loader, - * `unwrapExports`, the full plugin tree, the agent-core bundle, and the readline module. - * A dummy key permits startup; closing stdin before a prompt prevents network calls, - * while with-key suites cover product behavior. + * Keyless Loader-path smoke for examples/coding-agent: boot the real example + * through the stdio-agent bin and its `cordis.yml`, then close stdin without a + * prompt and assert the banner. The dummy key satisfies adapter construction; + * immediate EOF guarantees there is no model call. */ -// TODO(loader-smoke-harness): share spawn/tempdir/timeout/EOF setup with the other keyless smoke tests. -// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// The temp cwd cannot discover the root tsconfig used for unbuilt package aliases. -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// Allow cold Loader startup under parallel load while still detecting hangs. -const PROCESS_TIMEOUT_MS = 30_000 -// Let the child timeout report captured output before Vitest aborts. -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function bootAndEof(): Promise<{ stdout: string; code: number }> { - workdir = await mkdtemp(join(tmpdir(), 'coding-smoke-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:repl). - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TSX_TSCONFIG_PATH: repoTsconfig, - // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots. - // No prompt is sent, so the adapter never streams — no network call. - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - child = proc - let stdout = '' - let stderr = '' - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { stdout += chunk }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`coding-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, code }) - else reject(new Error(`coding-agent exited ${code}. stderr:\n${stderr}`)) - }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - - // No prompt — just EOF, so the stdio UI exits without ever running a turn. - proc.stdin.end() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + label: 'coding-agent', + tempDirPrefix: 'coding-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + }) expect(stdout).toContain('agent REPL ready.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index 24b7fc42cd..c1f20f1987 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -1,94 +1,27 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Keyless Loader-path smoke for examples/cordis-agent: boot the real example through the - * `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` — the cordis Loader, - * `unwrapExports`, the full plugin tree INCLUDING the `@deepseek-ai/dsh-tool-cordis` package - * resolved by name (whose `inject` would crash a collapsed export shape at load, see - * docs/postmortem/0001) — then close stdin with no prompt and assert the ready banner + a - * clean exit. A dummy key satisfies adapter boot, but no prompt means no network - * call; `cordis-tools.e2e.ts` owns the with-key product proof. + * Keyless Loader-path smoke for examples/cordis-agent: boot the real tree, + * including tool-cordis resolved by package name, then close stdin without a + * prompt and assert the banner. The dummy key never reaches a model call. */ -// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Dev/test run UNBUILT: resolve `@deepseek-ai/dsh-*` through the root tsconfig -// `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside -// the repo, so point it at the repo tsconfig (root is three levels up). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; -// 30s still detects a wedged child. -const PROCESS_TIMEOUT_MS = 30_000 -// Leave enough room for the process-owned timeout to report captured output -// before Vitest aborts the test itself. -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 - -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -async function bootAndEof(): Promise<{ stdout: string; code: number }> { - workdir = await mkdtemp(join(tmpdir(), 'cordis-smoke-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:cordis). - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TSX_TSCONFIG_PATH: repoTsconfig, - // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots. - // No prompt is sent, so the adapter never streams — no network call. - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - child = proc - let stdout = '' - let stderr = '' - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { stdout += chunk }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`cordis-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, code }) - else reject(new Error(`cordis-agent exited ${code}. stderr:\n${stderr}`)) - }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - - // No prompt — just EOF, so the stdio UI exits without ever running a turn. - proc.stdin.end() - }) -} +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) describe('cordis-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree incl. tool-cordis, prints its banner, and exits cleanly on EOF', async () => { - const { stdout, code } = await bootAndEof() - expect(code).toBe(0) + const { stdout } = await runLoaderSmoke({ + label: 'cordis-agent', + tempDirPrefix: 'cordis-smoke-', + binScript, + configPath, + tsconfigPath, + env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, + }) expect(stdout).toContain('cordis-agent ready.') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 6f15f1bff1..1bd2733d94 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -1,111 +1,43 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Keyless Loader-path smoke for examples/echo-agent: boot the real example through the - * `@deepseek-ai/dsh-stdio-agent` bin against this example's `cordis.yml` (the cordis Loader, - * `unwrapExports`, the whole plugin tree), pipe a script of stdin lines, and assert the - * rendered stdout. The mock adapter is network-free, making this the complete - * smoke; inputs cover both the echo-tool round trip and direct-reply branch. + * Keyless-by-nature Loader-path coverage for examples/echo-agent. The real + * tree uses its deterministic mock model, so this suite is both the boot smoke + * and the complete behavior proof for the example. */ -// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// The temp cwd is outside the repo, so point tsx at the root config that resolves -// unbuilt workspace packages through `paths`. -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; -// 30s still detects a wedged child. -const PROCESS_TIMEOUT_MS = 30_000 -// Leave enough room for the process-owned timeout to report captured output -// before Vitest aborts the test itself. -const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -let child: ChildProcessWithoutNullStreams | undefined -let workdir: string | undefined - -afterEach(async () => { - if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') - child = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined -}) - -/** - * Boot echo-agent, write `lines` to its stdin, close stdin, and resolve with - * the full stdout once the process exits (the stdio UI exits on EOF after the - * agent settles). Rejects on a non-zero exit or the process deadline. - */ -async function runEcho(lines: string[]): Promise<{ stdout: string; code: number }> { - workdir = await mkdtemp(join(tmpdir(), 'echo-smoke-')) - const cwd = workdir - return new Promise((resolve, reject) => { - const proc = spawn( - process.execPath, - // --expose-internals: the example's cordis.yml loads the HMR plugin, which requires it - // (mirrors the `demo:echo` script). - ['--expose-internals', '--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...process.env, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - child = proc - let stdout = '' - let stderr = '' - proc.stdout.setEncoding('utf8') - proc.stdout.on('data', (chunk: string) => { stdout += chunk }) - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - proc.kill('SIGKILL') - reject(new Error(`echo-agent did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, PROCESS_TIMEOUT_MS) - - proc.on('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve({ stdout, code }) - else reject(new Error(`echo-agent exited ${code}. stderr:\n${stderr}`)) - }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - - // Feed the script, then EOF so the stdio UI exits after the agent settles. - for (const line of lines) proc.stdin.write(`${line}\n`) - proc.stdin.end() +async function runEcho(stdinLines: readonly string[]): Promise { + const { stdout } = await runLoaderSmoke({ + label: 'echo-agent', + tempDirPrefix: 'echo-smoke-', + binScript, + configPath, + tsconfigPath, + stdinLines, }) + return stdout } describe('echo-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots, prints its welcome banner, and exits cleanly on stdin EOF', async () => { - const { stdout, code } = await runEcho([]) - expect(code).toBe(0) - expect(stdout).toContain('echo-agent ready.') - }, TEST_TIMEOUT_MS) + expect(await runEcho([])).toContain('echo-agent ready.') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('runs the echo tool round-trip for an "echo …" line', async () => { - const { stdout } = await runEcho(['echo hello world']) - // mock-llm.ts emits a tool-call for the echo tool; echo-tool.ts uppercases. + const stdout = await runEcho(['echo hello world']) expect(stdout).toContain('[tool call] echo') expect(stdout).toContain('[tool result] ECHO: HELLO WORLD') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('streams a direct canned reply for a non-echo line', async () => { - const { stdout } = await runEcho(['just chatting']) - // The direct-response branch of mock-llm.ts quotes the input back. + const stdout = await runEcho(['just chatting']) expect(stdout).toContain('just chatting') expect(stdout).not.toContain('[tool call]') - }, TEST_TIMEOUT_MS) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/knip.json b/knip.json index e8e8f173ae..d23887b5a9 100644 --- a/knip.json +++ b/knip.json @@ -33,6 +33,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/context/time-context": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/sandbox/sandbox-local": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] @@ -51,6 +55,11 @@ "project": ["src/**/*.ts", "tests/**/*.ts"], "ignoreDependencies": ["cordis"] }, + "packages/support/loader-smoke": { + "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["cordis"] + }, "packages/core/agent-loop": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] @@ -91,6 +100,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/stdio": { + "entry": ["tests/**/*.spec.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/jsonrpc-agent": { "project": ["src/**/*.ts"] }, diff --git a/package.json b/package.json index 323be73bc6..6f908aef1c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -69,11 +70,12 @@ "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", - "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts", + "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", + "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run docs:check", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", @@ -84,6 +86,7 @@ "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", "@stylistic/eslint-plugin": "^5.10.0", + "@types/js-yaml": "^4.0.9", "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", @@ -91,6 +94,7 @@ "eslint": "^10.4.1", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", + "js-yaml": "^4.2.0", "jscpd": "^5.0.12", "jsdom": "29.1.1", "knip": "^6.16.1", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1f92a850a9..766a188105 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). -- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md). +- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. diff --git a/packages/README.md b/packages/README.md index 13da4b50da..5cb4cfe419 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,6 @@ # Packages -Harness packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis plugin: a default `Service` subclass or functional plugin declaring ctx keys/events through declaration merging and contributing through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) and root [AGENTS.md](../AGENTS.md) § Conventions. +Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). ## Hierarchy @@ -16,6 +16,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | @@ -27,7 +28,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | -| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | +| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 73236b16b3..cf919ab836 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,6 +2,8 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. + ## Config ```yaml diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index e3c7ffe33b..381855b465 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 693cb3b45a..f6dfcea636 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -14,9 +14,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { DEFAULT_GRACE_MS, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' -export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' -export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts' - /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { /** Default working directory for commands (default: process.cwd()). */ @@ -170,7 +167,6 @@ export class LocalBashExecutor extends BashExecutor { const id = BashTaskId(`bash-${this.nextTaskId++}`) const task: TrackedTask = { id, - command: spec.command, status: 'running', exitCode: null, signal: null, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 8ed8b7699c..02e7a963be 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -184,27 +184,6 @@ export class OutputCollector { writeSync(this.spillFd, chunk) } - // TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at - // the bottom of this file) and `totalBytes` is read only by a test. The live - // background-poll path goes through `readFrom()`, so inline snapshot() into - // finalize() and drop or privatize the totalBytes getter. - /** - * Read the collected tail without finalizing (the final-result snapshot). - * @returns the retained tail text, the truncation flag, and the spill path when one was created. - */ - snapshot(): CollectedOutput { - return { - text: Buffer.concat(this.chunks).toString('utf8'), - truncated: this.dropped, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } - - /** Total bytes ever pushed (including bytes dropped from memory). */ - get totalBytes(): number { - return this.total - } - /** * Incremental read in whole-stream byte coordinates: returns everything * pushed since `fromByte`. When `fromByte` has already slid out of the @@ -243,7 +222,11 @@ export class OutputCollector { } this.spillFd = undefined } - return this.snapshot() + return { + text: Buffer.concat(this.chunks).toString('utf8'), + truncated: this.dropped, + ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, + } } } diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index fde84189cb..923b3adf7b 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local' -import type { RunningBash } from '@deepseek-ai/dsh-bash-local' +import { killGroup, OutputCollector, runBash } from '../src/run.ts' +import type { RunningBash } from '../src/run.ts' const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } })) vi.mock('node:fs', async (importOriginal) => { @@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (running.stdout.snapshot().text.includes(expected)) return + if (running.stdout.readFrom(0).text.includes(expected)) return await new Promise(resolve => setTimeout(resolve, 20)) } throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) @@ -295,19 +295,11 @@ describe('OutputCollector', () => { expect(third.spillPath).toBeDefined() }) - it('tracks totalBytes across drops', () => { - const collector = new OutputCollector(4, 'test', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - expect(collector.totalBytes).toBe(8) - expect(collector.finalize().text).toBe('bbbb') - }) - it('contains close failures and drops the spill path', () => { const collector = new OutputCollector(4, 'closefail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbb')) - expect(collector.snapshot().spillPath).toBeDefined() + expect(collector.readFrom(0).spillPath).toBeDefined() failNextClose.value = true let out: ReturnType diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 0077511946..b4f61abcbe 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-bash-local": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -36,6 +36,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index bfa71d73e3..2ae0566142 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -25,12 +25,12 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index d0cb702100..9fe9c2efed 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -215,7 +215,6 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed' /** A tracked background task handle. */ export interface BashTask { readonly id: BashTaskId - readonly command: string status: BashTaskStatus /** Exit code once finished (null = killed by signal / still running). */ exitCode: number | null diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index 94d299f175..bdee15aedd 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -34,7 +34,6 @@ class StubExecutor extends BashExecutor { start(spec: BashExecSpec): BashTask { const task: BashTask = { id: BashTaskId(`stub-${this.tasks.size + 1}`), - command: spec.command, status: 'running', exitCode: null, signal: null, diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 3979a3db4b..2c7fe345ad 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,6 +4,8 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +The package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests. + The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching). ## Tools diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index eec5d79ccb..4e23a928a7 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -29,7 +29,7 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index f66838bdde..ac4716b43f 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -21,7 +21,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { BashTask } from '@deepseek-ai/dsh-bash' +import { parseExitStatus, renderResult } from './render.ts' export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] @@ -118,65 +119,6 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { + 'it — but it does not forbid attempting or escalating other commands later.' } -/** Append the truncation notice (with the full-output spill path) to a stream's text. */ -function streamText(output: CollectedOutput): string { - if (!output.truncated) return output.text - return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` -} - -/** - * Shape one finished run into model-visible stdout, marked stderr, and status - * facts. Non-zero exits and sandbox denials remain ordinary results; only - * infrastructure failure or abort makes the tool call itself fail. - * - * @param result - the completed foreground run from the executor. - * @param escalationModes - the escalation targets this composition advertises; non-empty - * adds the same-turn escalation hint after a denial marker (default `[]`: no hint). - * @returns the model-facing text: output body (or `(no output)`), then any - * timeout/signal/exit markers, each on its own line. - */ -export function renderResult( - result: BashRunResult, - escalationModes: readonly SandboxMode[] = [], -): string { - const out = streamText(result.stdout) - const err = streamText(result.stderr) - - let body = out - if (err.length > 0) { - // Single newline between sections (stdout usually ends with one already). - if (body.length > 0 && !body.endsWith('\n')) body += '\n' - body += `[stderr]\n${err}` - } - if (body.length === 0) body = '(no output)' - - const markers: string[] = [] - // Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial, - // like a timeout, remains a reported fact for the model to handle. - if (result.sandbox?.denied) { - markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) - // Add the retry hint only when the schema advertises escalation, before - // the final exit marker. - if (escalationModes.length > 0) { - markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') - } - } - // Timeout is reported independently of how the process actually ended: a - // command can trap SIGTERM and exit 0 after our timer fired (e.g. - // `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 / - // signal:null — the model must still see that the command was cut short. - if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) - if (result.signal !== null) { - markers.push(`[killed by signal: ${result.signal}]`) - } else if (result.exitCode !== 0) { - markers.push(`[exit code: ${result.exitCode}]`) - } - if (markers.length === 0) return body - - if (!body.endsWith('\n')) body += '\n' - return body + markers.join('\n') -} - // Pure tool-owned presentation used for both live events and replay. /** @@ -224,18 +166,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } -/** - * Recover exit status from the final marked line emitted by {@link renderResult}. - * A program whose own final line exactly mimics a marker remains ambiguous for UI display. - */ -function parseExitStatus(text: string): { exitCode: number } | { signal: string } { - const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { signal: signal[1] } - const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } - return { exitCode: 0 } -} - /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */ function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView { return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts new file mode 100644 index 0000000000..924861bb1e --- /dev/null +++ b/packages/bash/tool-bash/src/render.ts @@ -0,0 +1,92 @@ +/** + * Model-facing result rendering for the bash tool. + * + * @module @deepseek-ai/dsh-tool-bash/render + */ + +import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * stderr section, then exit-status markers. Non-zero exits are REPORTED, not + * errored — the model decides how to react; only infrastructure failures + * (spawn errors, aborts) surface as isError results. + * @param result - the completed foreground run from the executor. + * @param escalationModes - the escalation targets this composition advertises; + * non-empty adds the same-turn escalation hint after a denial marker + * (default `[]`: no hint). + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderResult( + result: BashRunResult, + escalationModes: readonly SandboxMode[] = [], +): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // The sandbox marker precedes the exit-status markers so `[exit code: N]` + // stays the LAST line (exitStatus() anchors its parse there). Denial is a + // reported fact like timeout: the model decides how to react. + if (result.sandbox?.denied) { + markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) + // The same-turn nudge lives at the decision point: only when this + // composition advertises the fields (a lever is never hinted that the + // schema does not offer), and inside the sandbox marker family so the + // exit-code marker stays the last line. + if (escalationModes.length > 0) { + markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') + } + } + // Timeout is reported independently of how the process actually ended: a + // command can trap SIGTERM and exit 0 after our timer fired (e.g. + // `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 / + // signal:null — the model must still see that the command was cut short. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Recover the structured exit status from a rendered {@link renderResult} + * string — the inverse of the status markers it appends. A killed marker + * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both + * means a clean exit 0. + * + * Replay only retains the rendered content text, not the original + * `BashRunResult`, so terminal presentation must recover the exit pill here. + * Requiring a leading newline and the end of the string keeps ordinary output + * that merely ends with marker-like text from matching unless the final line + * is indistinguishable from a real marker. + * @param text - rendered model-facing bash result. + * @returns the recovered terminal exit code or signal. + */ +export function parseExitStatus(text: string): { exitCode: number } | { signal: string } { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } + return { exitCode: 0 } +} diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 3be0e1145b..dce5700afd 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -19,7 +19,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' -import { renderResult } from '@deepseek-ai/dsh-tool-bash' +import { renderResult } from '../src/render.ts' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) @@ -114,7 +114,6 @@ abstract class TestBashExecutor extends BashExecutor { class LossyReadBashExecutor extends TestBashExecutor { private readonly task: BashTask = { id: BashTaskId('bash-lossy'), - command: 'fake', status: 'running', exitCode: null, signal: null, @@ -1034,7 +1033,6 @@ describe('sandbox rendering', () => { class FactsOnlyExecutor extends TestBashExecutor { private readonly task: BashTask = { id: BashTaskId('bash-facts'), - command: 'fake', status: 'completed', exitCode: 1, signal: null, diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 91075243d2..c9d25ef4d8 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -28,13 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-code-runtime": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 0fe24bb15c..5380d26ace 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -22,9 +22,9 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index e57e28a8c9..32852a060f 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-compact": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -37,6 +37,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 99efd25b9c..985c42d3b2 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -24,11 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/context/README.md b/packages/context/README.md new file mode 100644 index 0000000000..49a297d11a --- /dev/null +++ b/packages/context/README.md @@ -0,0 +1,7 @@ +# context/ — optional request context + +Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-core` bundle excludes them. + +| Package | Role | ctx key | +|---|---|---| +| `time-context/` | Current time and elapsed-time system-prompt context | (none) | diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md new file mode 100644 index 0000000000..b50470aef7 --- /dev/null +++ b/packages/context/time-context/README.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-time-context + +Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-core` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). + +## Config + +```yaml +- id: time-context + name: '@deepseek-ai/dsh-time-context' + config: + timeZone: Asia/Shanghai # optional IANA override; omit for the process zone + refreshIntervalMs: 60000 # default; 0 refreshes on every step +``` + +When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work. + +## Message baseline + +The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time. + +The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history. + +## Model Experience + +### Temporal system prompt + +**What the model sees**: Every request in an active turn includes the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; `` is compact whole-second units or the first-turn fallback. + +**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate. + +#### Temporal context section + +```markdown +Current time: +Time since previous message: . +``` + +## Known Limitations and Deferred Work + +- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed. +- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000. +- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp. +- **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ. diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json new file mode 100644 index 0000000000..f319c7a5b1 --- /dev/null +++ b/packages/context/time-context/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-time-context", + "description": "Opt-in system-prompt context with the current time and elapsed time since the previous message", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts new file mode 100644 index 0000000000..cccd433811 --- /dev/null +++ b/packages/context/time-context/src/index.ts @@ -0,0 +1,183 @@ +/** + * Opt-in request-time clock context. Active turns receive the current zoned + * time and elapsed time since the preceding model-visible message. The loop + * logs each rendered value as request-header state rather than conversation + * history. + * + * @module @deepseek-ai/dsh-time-context + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'time-context' + +/** The system-prompt registry that owns the dynamic request section. */ +export const inject = ['systemPrompt'] + +/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +export interface Config { + /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ + timeZone?: string + /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ + refreshIntervalMs?: number +} + +/** Schemastery validation and defaults for {@link Config}. */ +export const Config: z = z.object({ + timeZone: z.string(), + refreshIntervalMs: z.number().default(60_000), +}) + +interface OpenTurn { + turn: number + startSeq: number +} + +/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */ +interface RenderState { + turn: number + renderedAt: number + previousMessageTime: number | undefined + text: string +} + +type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' + +function openTurn(agent: Agent): OpenTurn | undefined { + for (const event of [...agent.session.events].reverse()) { + switch (event.type) { + case 'turn/end': + return undefined + case 'turn/start': + return { turn: event.data.turn, startSeq: event.seq } + default: + // Merge-extensible session events: only turn boundaries matter here. + break + } + } + return undefined +} + +/** Find the latest model-visible timestamp strictly before one turn boundary. */ +function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.seq >= turnStartSeq) continue + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'tool/result': + case 'context/message': + case 'steering/message': + return event.time + default: + // Merge-extensible session events: non-surface records are not messages. + break + } + } + return undefined +} + +/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */ +function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string { + const parts = Object.fromEntries( + formatter.formatToParts(now).map(part => [part.type, part.value]), + ) as Record + const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3) + return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]` +} + +/** Format a non-negative elapsed millisecond count as compact whole-second units. */ +function formatDuration(elapsedMs: number): string { + let seconds = Math.floor(Math.max(0, elapsedMs) / 1000) + const days = Math.floor(seconds / 86_400) + seconds %= 86_400 + const hours = Math.floor(seconds / 3600) + seconds %= 3600 + const minutes = Math.floor(seconds / 60) + seconds %= 60 + const parts: string[] = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + parts.push(`${seconds}s`) + return parts.join(' ') +} + +function renderText( + now: number, + previous: number | undefined, + formatter: Intl.DateTimeFormat, + timeZone: string, +): string { + const elapsed = previous === undefined + ? 'unavailable (no earlier message in this session)' + : formatDuration(now - previous) + return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.` +} + +/** + * Register the request-time clock section for the lifetime of `ctx`. + * @param ctx - plugin context; the section registration is disposed with it. + * @param config - validated time zone and intra-turn refresh interval. + * @throws when the time zone or refresh interval is invalid. + */ +export function apply(ctx: Context, config: Config): void { + const timeZone = config.timeZone + const refreshIntervalMs = config.refreshIntervalMs as number + if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) { + throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`) + } + + let formatter: Intl.DateTimeFormat + try { + formatter = new Intl.DateTimeFormat('en-US', { + ...(timeZone === undefined ? {} : { timeZone }), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'longOffset', + }) + } catch (error: unknown) { + const message = timeZone === undefined + ? 'time-context: failed to resolve the system time zone' + : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}` + throw new Error(message, { cause: error }) + } + const resolvedTimeZone = formatter.resolvedOptions().timeZone + const states = new WeakMap() + + ctx.systemPrompt.section({ + name: 'context:time', + order: 10, + text(context: AssembleContext): string { + const agent = context.agent + if (agent === undefined) return '' + const currentTurn = openTurn(agent) + if (currentTurn === undefined) return '' + + const now = Date.now() + const prior = states.get(agent) + if (prior !== undefined + && prior.turn === currentTurn.turn + && now >= prior.renderedAt + && now - prior.renderedAt < refreshIntervalMs) { + return prior.text + } + + const previous = prior?.turn === currentTurn.turn + ? prior.previousMessageTime + : previousMessageTime(agent, currentTurn.startSeq) + const text = renderText(now, previous, formatter, resolvedTimeZone) + states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text }) + return text + }, + }) +} diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/packages/context/time-context/tests/fixtures/cordis.yml new file mode 100644 index 0000000000..e9558abec6 --- /dev/null +++ b/packages/context/time-context/tests/fixtures/cordis.yml @@ -0,0 +1,17 @@ +# Test-only composition: keep time-context opt-in while exercising its real Loader/app path. +- id: mock-llm + name: '../../../../../examples/echo-agent/src/mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: time-context + name: '@deepseek-ai/dsh-time-context' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: mock-echo + persona: 'Test the time-context plugin.' + welcome: 'time-context e2e ready.' + persistenceRoot: './.sessions' diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts new file mode 100644 index 0000000000..daa6e9a9b8 --- /dev/null +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -0,0 +1,115 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session' + +const binScript = fileURLToPath(new URL('../../../ui/stdio-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const PROCESS_TIMEOUT_MS = 30_000 +const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 +const FIRST_REPLY = 'You said: "first". Try "echo " to see a tool call.' + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const proc = spawn( + process.execPath, + ['--expose-internals', '--import', tsxLoader, binScript, configPath], + { + cwd, + env: { + ...process.env, + TZ: 'Asia/Shanghai', + TSX_TSCONFIG_PATH: repoTsconfig, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + child = proc + let stdout = '' + let stderr = '' + let sentSecond = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) { + sentSecond = true + proc.stdin.end('second\n') + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('first\n') + }) +} + +describe('time-context through a real cordis.yml and stdio process', () => { + it('uses the process zone and persists both first-turn and elapsed-time request context', async () => { + const { stdout, stderr } = await runTwoTurns() + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('time-context e2e ready.') + expect(stdout).toContain(FIRST_REPLY) + expect(stdout).toContain('You said: "second".') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const firstHeader = events.find(event => event.type === 'request/header') + if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event') + expect(firstHeader.data.header.system).toMatch( + /Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, + ) + expect(firstHeader.data.header.system).toContain( + 'Time since previous message: unavailable (no earlier message in this session).', + ) + + const finalSystem = foldRequestHeader(events)?.system + expect(finalSystem).toContain('[Asia/Shanghai]') + expect(finalSystem).toMatch( + /Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, + ) + }, TEST_TIMEOUT_MS) +}) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts new file mode 100644 index 0000000000..562b002b68 --- /dev/null +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as timeContext from '@deepseek-ai/dsh-time-context' +import type { Config } from '@deepseek-ai/dsh-time-context' + +const BASE = Date.parse('2026-07-14T00:00:00.000Z') +const ORIGINAL_TIME_ZONE = process.env['TZ'] + +beforeEach(() => { + process.env['TZ'] = 'UTC' + vi.useFakeTimers() + vi.setSystemTime(BASE) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + if (ORIGINAL_TIME_ZONE === undefined) delete process.env['TZ'] + else process.env['TZ'] = ORIGINAL_TIME_ZONE +}) + +async function mount(config: Config = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const fiber = await ctx.plugin(timeContext, config) + return { ctx, fiber } +} + +function sessionAgent(session: Session, id = 'agent'): Agent { + return { id: AgentId(id), session } as unknown as Agent +} + +async function sectionText(ctx: Context, agent?: Agent): Promise { + const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent }) + return assembly.sections.find(section => section.name === 'context:time')?.text +} + +function openMessageTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +function toolCallResponse(): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: CallId('tick-1'), name: 'tick', arguments: '{}' }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: StreamChunk[][]) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const chunks = this.script.shift() + if (chunks === undefined) throw new Error('ScriptedAdapter: script exhausted') + for (const chunk of chunks) yield chunk + } +} + +async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(timeContext, config) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +describe('temporal section rendering', () => { + it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('first')) + openMessageTurn(session, 1) + + expect(await sectionText(ctx, sessionAgent(session))).toBe( + 'Current time: 2026-07-14T00:00:00+00:00[UTC]\n' + + 'Time since previous message: unavailable (no earlier message in this session).', + ) + }) + + it('renders a non-UTC numeric offset and all compact duration units', async () => { + const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) + const session = new Session(SessionId('offset')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'previous' }], + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + vi.setSystemTime(BASE + 90_061_000) + openMessageTurn(session, 2) + + expect(await sectionText(ctx, sessionAgent(session))).toBe( + 'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Time since previous message: 1d 1h 1m 1s.', + ) + }) + + it('clamps a backward wall-clock adjustment to a zero duration', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('backward-duration')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'future by adjusted clock' }], + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + vi.setSystemTime(BASE - 5_000) + openMessageTurn(session, 2) + + expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.') + }) + + const previousMessageCases = [ + ['user/message', (session: Session): void => { + session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + }], + ['assistant/message', (session: Session): void => { + session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' }) + }], + ['tool/result', (session: Session): void => { + session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('previous'), + content: [{ type: 'text', text: 'r' }], + isError: false, + }, { surfaceOp: 'append' }) + }], + ['context/message', (session: Session): void => { + session.append('context/message', { + content: [{ type: 'text', text: 'c' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + }], + ['steering/message', (session: Session): void => { + session.append('steering/message', { + turn: 1, + content: [{ type: 'text', text: 's' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + }], + ] as const + + it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => { + const { ctx } = await mount() + const session = new Session(SessionId(`previous-${_name}`)) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendPrevious(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + vi.setSystemTime(BASE + 5_000) + openMessageTurn(session, 2) + + expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.') + }) + + it('contributes empty text without an active agent turn', async () => { + const { ctx } = await mount() + expect(await sectionText(ctx)).toBe('') + + const empty = sessionAgent(new Session(SessionId('empty'))) + expect(await sectionText(ctx, empty)).toBe('') + + const closedSession = new Session(SessionId('closed')) + openMessageTurn(closedSession, 1) + closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('') + }) +}) + +describe('refresh policy', () => { + it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => { + const { ctx } = await mount({ refreshIntervalMs: 60_000 }) + const session = new Session(SessionId('interval')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + + const first = await sectionText(ctx, agent) + vi.setSystemTime(BASE + 30_000) + expect(await sectionText(ctx, agent)).toBe(first) + vi.setSystemTime(BASE + 60_000) + const expired = await sectionText(ctx, agent) + expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]') + vi.setSystemTime(BASE + 59_000) + expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]') + }) + + it('refreshes every assembly when refreshIntervalMs is zero', async () => { + const { ctx } = await mount({ refreshIntervalMs: 0 }) + const session = new Session(SessionId('every-step')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + const first = await sectionText(ctx, agent) + vi.setSystemTime(BASE + 1_000) + expect(await sectionText(ctx, agent)).not.toBe(first) + }) + + it('always refreshes for a new turn and keeps the preceding message baseline', async () => { + const { ctx } = await mount({ refreshIntervalMs: 60_000 }) + const session = new Session(SessionId('turn-refresh')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + const first = await sectionText(ctx, agent) + vi.setSystemTime(BASE + 1_000) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'done' }], + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + vi.setSystemTime(BASE + 2_000) + openMessageTurn(session, 2) + + const second = await sectionText(ctx, agent) + expect(second).not.toBe(first) + expect(second).toContain('Time since previous message: 1s.') + }) + + it('keeps refresh caches independent per agent', async () => { + const { ctx } = await mount({ refreshIntervalMs: 60_000 }) + const sessionA = new Session(SessionId('agent-a')) + const sessionB = new Session(SessionId('agent-b')) + const agentA = sessionAgent(sessionA, 'a') + const agentB = sessionAgent(sessionB, 'b') + openMessageTurn(sessionA, 1) + openMessageTurn(sessionB, 1) + const aFirst = await sectionText(ctx, agentA) + vi.setSystemTime(BASE + 30_000) + const bFirst = await sectionText(ctx, agentB) + vi.setSystemTime(BASE + 40_000) + + expect(await sectionText(ctx, agentA)).toBe(aFirst) + expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]') + }) +}) + +describe('configuration and lifecycle', () => { + it('defaults to the process system zone and retains the zone resolved at plugin load', async () => { + process.env['TZ'] = 'Asia/Shanghai' + const { ctx } = await mount() + process.env['TZ'] = 'America/New_York' + const session = new Session(SessionId('system-zone')) + openMessageTurn(session, 1) + + expect(await sectionText(ctx, sessionAgent(session))).toContain( + 'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]', + ) + }) + + it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => { + for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/) + } + + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/) + }) + + it('fails loud when the process system zone cannot be resolved', async () => { + vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => { + throw new RangeError('system zone unavailable') + }) + const ctx = new Context() + await ctx.plugin(SystemPrompt) + + await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) + }) + + it('removes its section when the plugin fiber disposes', async () => { + const { ctx, fiber } = await mount() + const session = new Session(SessionId('dispose')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + expect(await sectionText(ctx, agent)).toContain('Current time:') + + await fiber.dispose() + expect(await sectionText(ctx, agent)).toBeUndefined() + }) +}) + +describe('real agent-loop request logging', () => { + it('refreshes a long turn in the system prompt and records the header delta without context history', async () => { + const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')]) + const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 }) + ctx.tools.register(defineTool({ + name: 'tick', + description: 'advance fake time', + parameters: {}, + async execute() { + vi.setSystemTime(BASE + 61_000) + return [{ type: 'text' as const, text: 'advanced' }] + }, + })) + const agent = ctx.agentLoop.create(AgentId('loop'), { model: 'mock' }) + + agent.send([{ type: 'text', text: 'start' }]) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]') + expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]') + expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) + expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1) + expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system) + + vi.setSystemTime(BASE + 361_000) + agent.send([{ type: 'text', text: 'again' }]) + await agent.whenIdle() + expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.') + await ctx.fiber.dispose() + }) +}) + +describe('real Loader export path', () => { + it('keeps the namespace metadata and boots through unwrapExports', async () => { + expect('default' in timeContext).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(timeContext) as Record + expect(unwrapped).toBe(timeContext) + expect(unwrapped.name).toBe('time-context') + expect(unwrapped.inject).toEqual(['systemPrompt']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const plugin = loader.unwrapExports(timeContext) as Parameters[0] + await ctx.plugin(plugin) + const session = new Session(SessionId('loader')) + openMessageTurn(session, 1) + expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:') + }) +}) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json new file mode 100644 index 0000000000..eda3a81772 --- /dev/null +++ b/packages/context/time-context/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/agent" } + ] +} diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index a7d99eeea3..fd9c35e48e 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -37,8 +37,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7", "@cordisjs/plugin-timer": "workspace:^" } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e2eed7ddd4..f1d196c746 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -240,8 +240,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ 'registerSearchProvider(provider: WebSearchProvider): () => void', 'registerFetchProvider(provider: WebFetchProvider): () => void', - 'async search(request: WebSearchRequest, exec?: WebExecContext): Promise', - 'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise', + 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', + 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise', ], }, { @@ -387,18 +387,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, - { - name: 'skill/provider-added', - mode: 'emit', - signature: '\'skill/provider-added\'(provider: SkillProvider): void', - summary: 'A skill provider became resolvable in the `ctx.skills` registry.', - }, - { - name: 'skill/provider-removed', - mode: 'emit', - signature: '\'skill/provider-removed\'(name: string): void', - summary: 'A skill provider left the registry because its plugin fiber was disposed.', - }, { name: 'subagent/end', mode: 'emit', @@ -587,7 +575,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BashTask', - declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: BashSandboxInfo;\n}', + declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: BashSandboxInfo;\n}', }, { name: 'BashTaskId', @@ -991,7 +979,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionResult', - declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', + declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}', }, { name: 'ToolExecutionToken', @@ -1045,33 +1033,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'UserInteractionProvider', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise;\n}', }, - { - name: 'WebExecContext', - declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}', - }, { name: 'WebFetchBody', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', }, { name: 'WebFetchProvider', - declaration: 'export interface WebFetchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n fetch(request: WebFetchRequest, exec?: WebExecContext): Promise;\n}', + declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise;\n}', }, { name: 'WebFetchRequest', - declaration: 'export interface WebFetchRequest {\n readonly url: string;\n readonly timeoutMs?: number;\n}', + declaration: 'export interface WebFetchRequest {\n readonly url: string;\n}', }, { name: 'WebFetchResult', - declaration: 'export interface WebFetchResult {\n readonly providerId: string;\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}', - }, - { - name: 'WebProviderStatus', - declaration: 'export type WebProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'missing-credential\' | \'misconfigured\';\n};', + declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}', }, { name: 'WebSearchProvider', - declaration: 'export interface WebSearchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n search(request: WebSearchRequest, exec?: WebExecContext): Promise;\n}', + declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise;\n}', }, { name: 'WebSearchRequest', @@ -1079,7 +1059,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WebSearchResult', - declaration: 'export interface WebSearchResult {\n readonly providerId: string;\n readonly query: string;\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}', + declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}', }, { name: 'WebSearchSource', diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index 039a6ac505..2c2b25e772 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-tool-bash": "^0.0.1", "@deepseek-ai/dsh-tool-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-timer": "workspace:^", @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 5d180bd08c..7e2fb235a2 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -28,7 +28,7 @@ "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 18eca40cc1..15cc0aa410 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -578,7 +578,8 @@ async function runStep( }) session.append('tool/result', { turn, step, - // Preserve transcript pairing even if a post-execute listener returns another id. + // Correlation comes from the immutable execution input; the result does + // not duplicate this authoritative transcript identity. callId: call.id, content: result.content, isError: result.isError, diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 8130d4b893..ee50261e9d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1043,8 +1043,7 @@ describe('tool result call identity', () => { // A post-execute listener transforms the result (accept-with-replacement). // The loop must still record the tool/result under the model's authoritative - // call.id (the loop ignores result.callId — which the registry always sets to - // exec.callId anyway — and uses call.id, the model-transcript id). + // call.id, which is the immutable identity carried by the execution input. ctx.on('tools/post-execute', (exec, _result) => { expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) @@ -1054,8 +1053,7 @@ describe('tool result call identity', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - // The logged tool/result.callId is the originating call.id, NOT the - // listener's wrong id. + // The logged tool/result.callId is the originating call.id. const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index b8e6108904..72cd18942e 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -27,7 +27,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -35,6 +35,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 89c2b4428b..88d78ceb8b 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -22,9 +22,9 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0c4dfbe9fc..7e201ab647 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback. - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. -- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite or invalidation. +- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 454a0d7cc3..540c5cdc75 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -25,12 +25,12 @@ "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 28312abb7a..89f1627445 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -41,6 +41,7 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners * receive only sessions entered through that agent's context. * @param session - the session just entered and announced. + * @dshScopeScan unsupported * @mode emit */ 'session/created'(this: Scoped, session: Session): void @@ -50,6 +51,7 @@ declare module 'cordis' { * did not begin. Listener failures are logged and contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. * @param session - the session that is no longer live in the store. + * @dshScopeScan unsupported * @mode emit */ 'session/disposed'(this: Scoped, session: Session): void @@ -61,6 +63,7 @@ declare module 'cordis' { * receive only events from sessions entered through that agent's context. * @param session - the session whose log grew. * @param event - the appended event, exactly as recorded. + * @dshScopeScan unsupported * @mode emit */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void @@ -70,6 +73,7 @@ declare module 'cordis' { * {@link SessionStore.flush}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. + * @dshScopeScan unsupported * @mode parallel */ 'session/flush'(this: Scoped, session: Session): Promise | void diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 5f7dac8254..37eebbf7c3 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -193,26 +193,14 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult export class SurfaceManager { /** Incremental state shared with the complete surface fold. */ private _state = createFoldState() - /** The last processed seq. -1 forces a full rebuild on first access. */ + /** The last processed seq. -1 folds the seeded log on first access. */ private _lastProcessedSeq = -1 constructor(private log: readonly SessionEvent[]) {} /** - * Reset to unprocessed state. Call after the log has been replaced - * wholesale (e.g. after Session seed). Not needed for normal appends — - * those are picked up incrementally. - */ - invalidate(): void { - this._lastProcessedSeq = -1 - // A wholesale rebuild is a rewrite: bump the generation so incremental - // consumers (the session's derived-message cache) discard their view. - this._state = createFoldState(this._state.replaceGeneration + 1) - } - - /** - * The surface's rewrite generation: bumped by every folded `replace` op and - * by {@link invalidate}. A replace is the ONE operation that rewrites the + * The surface's rewrite generation, bumped by every folded `replace` op. + * A replace is the ONE operation that rewrites the * surface non-monotonically, so an incremental consumer of {@link nodes} * (the session's derived-message cache) compares this between visits — an * unchanged generation guarantees every node it has not seen is a pure tail diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 5d2047d9a5..5314c5e88f 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -1,6 +1,6 @@ /** * Derived-message cache contract against a scratch oracle: project new nodes - * once, rebuild on surface generation changes, return fresh arrays over shared + * once, rebuild on surface replacements, return fresh arrays over shared * frozen messages, and remain value-equal to replay at every step. */ @@ -61,16 +61,6 @@ describe('derived-message cache', () => { expect(Object.isFrozen(first[0])).toBe(true) }) - it('rebuilds after surface.invalidate() (the generation covers wholesale rebuilds too)', () => { - const session = new Session(SessionId('cache-invalidate')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - userText(session, 'one') - const before = session.deriveMessages() - session.surface.invalidate() - const after = session.deriveMessages() - expect(after).toEqual(before) - expect(after[0]).not.toBe(before[0]) - }) }) describe('Session.deriveEventMessage — the per-event projection', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 55dd3dc0ef..eb15cf3317 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -81,14 +81,6 @@ describe('SurfaceManager', () => { expect(nodes[1]!.next).toBeNull() }) - it('invalidate resets to full rebuild', () => { - const s = surfaceSession() - expect(s.surface.nodes.length).toBe(2) - // After invalidate, the surface should rebuild from scratch on next access. - ;(s.surface).invalidate() - expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt - }) - it('empty surface yields empty nodes', () => { const s = new Session(SessionId('empty')) // Only turn boundaries, no surface nodes. @@ -386,7 +378,7 @@ describe('surface type guards', () => { }) describe('SurfaceManager.replaceGeneration', () => { - it('folds the pending log delta on access and counts replaces and invalidations', () => { + it('folds the pending log delta on access and counts replaces', () => { const s = new Session(SessionId('gen')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -400,10 +392,5 @@ describe('SurfaceManager.replaceGeneration', () => { content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' }, }, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] }) expect(s.surface.replaceGeneration).toBe(1) - - // invalidate() is a rewrite too: the generation moves forward (and the - // refold re-counts the replace), never backwards. - s.surface.invalidate() - expect(s.surface.replaceGeneration).toBeGreaterThan(1) }) }) diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 120e10ef11..67161b79b4 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 9a8a36face..566395e55a 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -226,7 +226,7 @@ export class SystemPrompt extends Service { private scopedVariableProviders = new Map string | undefined>>() private readonly toolOrder: string[] | undefined - constructor(ctx: Context, public config: Config) { + constructor(ctx: Context, config: Config) { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) // Keep harness-owned openers independent of the selected loop plugin. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5b2a81528b..0f7974e0f9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -36,7 +36,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. 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. +- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, 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. - `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?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns. - `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch. diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 9b4b80d67c..2fe3cbd448 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -29,7 +29,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -42,6 +42,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 988c6a0268..d0c75e8035 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -220,7 +220,7 @@ export interface ToolErrorInfo { * distinguish it from a tool body's own error. */ export class ToolNotFoundError extends HarnessError { - constructor(public readonly toolName: string) { + constructor(toolName: string) { super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL') this.name = 'ToolNotFoundError' } @@ -228,7 +228,6 @@ export class ToolNotFoundError extends HarnessError { /** The outcome of one tool call. */ export interface ToolExecutionResult { - callId: CallId content: ContentBlock[] isError: boolean /** @@ -704,7 +703,7 @@ export class ToolRegistry extends Service { } } catch (error: unknown) { execution = { ...base, arguments: undefined } - const result = this.materializeFinalResult(toolErrorResult(callId, error)) + const result = this.materializeFinalResult(toolErrorResult(error)) this.notifyResult(execution, result) return result } @@ -714,7 +713,7 @@ export class ToolRegistry extends Service { } catch (error: unknown) { // Outer backstop: a throwing pre/post-execute listener, guard, or the // waterfall machinery becomes an isError result, never a turn failure. - result = this.materializeFinalResult(toolErrorResult(execution.callId, error)) + result = this.materializeFinalResult(toolErrorResult(error)) } this.notifyResult(execution, result) return result @@ -739,7 +738,6 @@ export class ToolRegistry extends Service { // Every non-grant, including a failed/unavailable approval request, takes // the same deny path and still reaches post-policy plus result observers. const denied: ToolExecutionResult = { - callId: exec.callId, content: [{ type: 'text', text: `Error: ${denialReason}` }], isError: true, } @@ -770,16 +768,12 @@ export class ToolRegistry extends Service { const returned = await tool.execute(exec.arguments, exec) const content = Array.isArray(returned) ? returned : returned.content const meta = Array.isArray(returned) ? undefined : returned.meta - return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } + return { content, isError: false, ...meta !== undefined ? { meta } : {} } } catch (error: unknown) { - return toolErrorResult(exec.callId, error) + return toolErrorResult(error) } }, ) - if (result.callId !== exec.callId) { - throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`) - } - return await this.postExecute(exec, result) } @@ -854,7 +848,6 @@ export class ToolRegistry extends Service { const additionalContext = decision.additionalContext if (decision.kind === 'block') { return { - callId: result.callId, content: decision.feedback, isError: true, ...additionalContext ? { additionalContext } : {}, @@ -883,10 +876,9 @@ function createExecutionToken(): ToolExecutionToken { return Symbol('dsh.tool.execution') as ToolExecutionToken } -function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult { +function toolErrorResult(error: unknown): ToolExecutionResult { const info = errorInfo(error) return { - callId, content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }], isError: true, ...info ? { error: info } : {}, diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6599112c2a..d4851cdbd7 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => { expect(reads).toBe(1) expect(result).toEqual({ - callId: CallId('unstable-arguments'), content: [{ type: 'text', text: 'ran:t' }], isError: false, }) @@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => { ctx.on('internal/dispatch', (mode, name) => { if (name === 'tools/result') dispatchModes.push(mode) }) - ctx.on('tools/execute', async (exec, next) => { + ctx.on('tools/execute', async (_exec, next) => { await next() return { - callId: exec.callId, content: [{ type: 'text', text: 'outer failure' }], isError: true, } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index b171817e35..59bd34e7ab 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -80,7 +80,7 @@ describe('ToolRegistry', () => { const ctx = await setup() ctx.tools.register(echoTool) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) - expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) }) it('threads a tool-attached meta (object return form) onto the result', async () => { @@ -94,7 +94,6 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, @@ -111,7 +110,7 @@ describe('ToolRegistry', () => { }, }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) - expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) expect('meta' in result).toBe(false) }) @@ -178,13 +177,12 @@ describe('ToolRegistry', () => { }) }) - it('ToolNotFoundError carries the tool name and a stable code', async () => { + it('ToolNotFoundError carries a stable message and code', async () => { const { HarnessError } = await import('@deepseek-ai/dsh-llm') const err = new ToolNotFoundError('ghost') expect(err).toBeInstanceOf(HarnessError) expect(err.name).toBe('ToolNotFoundError') expect(err.code).toBe('UNKNOWN_TOOL') - expect(err.toolName).toBe('ghost') expect(err.message).toBe('unknown tool "ghost"') }) @@ -425,7 +423,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) - expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) // The around seam wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) @@ -526,8 +524,8 @@ describe('ToolRegistry', () => { async execute() { dispatched = true; return [] }, }) - ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise): Promise => - ({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false })) + ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => + ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch @@ -537,8 +535,7 @@ describe('ToolRegistry', () => { it('preserves additionalContext supplied by an around-dispatch result', async () => { const ctx = await setup() ctx.tools.register(echoTool) - ctx.on('tools/execute', async exec => ({ - callId: exec.callId, + ctx.on('tools/execute', async () => ({ content: [{ type: 'text', text: 'short-circuited with context' }], isError: false, additionalContext: { @@ -556,20 +553,6 @@ describe('ToolRegistry', () => { }) }) - it('normalizes a tools/execute result with the wrong call id', async () => { - const ctx = await setup() - ctx.tools.register(echoTool) - ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false })) - - const result = await ctx.tools.execute({ - callId: CallId('malformed-shape'), name: 'echo', arguments: {}, - }) - expect(result.isError).toBe(true) - expect(result.content[0]).toMatchObject({ - text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"', - }) - }) - it('returns an isError result when a tools/execute listener throws', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -577,7 +560,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: wrapper broke' }], isError: true, }) @@ -593,7 +575,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: permission hook broke' }], isError: true, }) @@ -609,7 +590,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: post hook broke' }], isError: true, }) @@ -625,7 +605,6 @@ describe('ToolRegistry', () => { const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ - callId: CallId('c1'), isError: true, error: { name: 'HarnessError', code: 'DENIED' }, }) @@ -1254,7 +1233,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => { }, })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) - expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false }) }) it('ToolArgsError carries a stable code and the violation list', () => { diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 7766a4a954..e950385223 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). -The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. +The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. ## Model Experience diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 4945684713..dd80cb4d9c 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -23,7 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -31,6 +31,6 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index d3e049199c..43ca988338 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -31,20 +31,6 @@ import { } from './fsio.ts' import type { FsIoInternals } from './fsio.ts' -export { - applyLiteralEdit, - listDirectory, - probe, - readForEdit, - readTextForDiff, - readWholeText, - resolveLocalTarget, - restoreLineEndings, - streamWholeText, - writeFileAtomic, -} from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts' - /** Configuration for the local filesystem backend. */ export interface Config { /** Base directory for relative paths. Defaults to `process.cwd()`. */ diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 3a30f73ed2..6723ae9d9c 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -20,8 +20,8 @@ import { restoreLineEndings, streamWholeText, writeFileAtomic, -} from '@deepseek-ai/dsh-fs-local' -import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' +} from '../src/fsio.ts' +import type { LocalTarget } from '../src/fsio.ts' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index c3f2a07982..e27302e4a6 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -23,11 +23,11 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 813cb04e16..f1efde152a 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -24,11 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index fc17663e48..bf3633a253 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -46,7 +46,7 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 7e7b78aa38..c21c806e98 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 16d352f1b7..d29e4ae733 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -12,15 +12,6 @@ import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' -export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' -export type { ReadToolCaps } from './read.ts' -export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' -export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' -export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts' -export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts' -export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts' -export type { FsDiffMeta } from './diff.ts' - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/tests/diff.spec.ts b/packages/fs/tool-fs/tests/diff.spec.ts index 12ab7209b6..21f977f0fa 100644 --- a/packages/fs/tool-fs/tests/diff.spec.ts +++ b/packages/fs/tool-fs/tests/diff.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs' +import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '../src/diff.ts' import type { JsonValue } from '@deepseek-ai/dsh-session' const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n' diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index c23ad79170..ab4d2a618b 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' -import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs' +import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' +import type { ReadWindow } from '../src/read-render.ts' const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS } diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 1fdb75637a..e4367b8702 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -20,8 +20,9 @@ import type { } from '@deepseek-ai/dsh-fs' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' -import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' +import { STREAM_MIN_SIZE } from '../src/read.ts' +import { formatReadOutput } from '../src/read-render.ts' +import type { FileReadOutcome } from '../src/read-render.ts' /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 9b085bb015..0cc99b6976 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -27,7 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -36,6 +36,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 2220220769..201c744219 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -24,11 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 5cc39f9999..21f08965d8 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index f26b57fe11..fe667b0302 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +42,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 8ebf71c5b5..1461ad0f44 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -23,13 +23,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 30911915ff..c922467deb 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -23,7 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "@earendil-works/pi-ai": "^0.79.1", @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 1dc84e13d7..ab11c8574f 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -23,10 +23,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index d2f8b34161..9dd90a3e9a 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "node-addon-landlock-run": "0.0.0-test.0", @@ -33,6 +33,6 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 62cb56dc31..8519357c1b 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -71,7 +71,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional // dependencies because the launcher selects its OS/CPU package through one. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) - const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], { + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], { cwd: consumerDir, encoding: 'utf8', timeout: 300_000, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index b37ef714ea..50c5b443ba 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -23,10 +23,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index ac18a38838..ddb9f2af4d 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index d5922afa94..1d13ff424e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,7 +14,7 @@ import { SessionPersistence, PersistenceCoordinator, type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine, } from './format.ts' @@ -83,15 +83,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. - /** - * The per-session init promises, exposed for white-box tests that await a - * specific session's onCreated (there is no public API to await one init). - */ - get inits(): Map> { - return this.coordinator.inits - } /* jscpd:ignore-end */ - // --- PersistenceBackend hooks (the file-bytes storage primitives) --- /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index a4f0334f50..75fcb48732 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -20,6 +20,19 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } +async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(AggregateError) + const [cause] = (error as AggregateError).errors as unknown[] + expect(cause).toBeInstanceOf(Error) + expect((cause as Error).message).toMatch(message) + return + } + throw new Error('expected parallel flush to reject') +} + async function freshRoot(): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-')) dirs.push(dir) @@ -488,12 +501,11 @@ describe('SessionPersistenceJsonl: edge cases', () => { // A new Session object reuses the id. Object-keyed initialization must run independently, // detect the disk collision, and reject instead of appending through session A's stale cursor. - const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) - await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) + await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/) }) it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => { @@ -511,12 +523,11 @@ describe('SessionPersistenceJsonl: edge cases', () => { const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) - const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx2.plugin(Object.assign((inner: Context) => { b = inner.sessions.create(SessionId('x')) // no cwd }, { inject: ['sessions'] })) - await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/) + await expect(ctx2.sessions.flush(b)).rejects.toThrow(/already has a persisted log on disk/) // The "/w" log is untouched — no no-cwd events were grafted onto it, and no // `_no-cwd` log for "x" was created. @@ -533,7 +544,6 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog()) await ctx.sessionPersistence.load(SessionId('divergent')) - const backend = ctx.sessionPersistence as unknown as { inits: Map> } // A seed that keeps every seq/type/time but mutates a payload must NOT be // accepted as "the same session" — otherwise drain filters those seqs as // already persisted and the divergent payload is silently lost. @@ -544,7 +554,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.plugin(Object.assign((inner: Context) => { bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) - await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/) + await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/) }) it('a second live session reusing a bound id is rejected', async () => { @@ -557,12 +567,11 @@ describe('SessionPersistenceJsonl: edge cases', () => { for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) await firstFiber.dispose() - const backend = ctx.sessionPersistence as unknown as { inits: Map> } let second!: Session await ctx.plugin(Object.assign((inner: Context) => { second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } }) }, { inject: ['sessions'] })) - await expect(backend.inits.get(second)) + await expect(ctx.sessions.flush(second)) .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/) }) @@ -594,12 +603,11 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE - const backend = ctx2.sessionPersistence as unknown as { inits: Map> } let s!: Session await ctx2.plugin(Object.assign((inner: Context) => { s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } }) }, { inject: ['sessions'] })) - await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/) + await expect(ctx2.sessions.flush(s)).rejects.toThrow(/ENOTDIR/) await ctx2.fiber.dispose() }) @@ -670,7 +678,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise } const origMat = backend.materialize.bind(backend) backend.materialize = () => Promise.reject(new Error('disk full')) - await expect(ctx2.parallel('session/flush', session)).rejects.toThrow(/disk full/) + await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/) // The events are STILL buffered (not silently dropped): a retry persists them. backend.materialize = origMat await ctx2.parallel('session/flush', session) diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index b26c69461e..f367b737b3 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 32b8f4ade8..7d23292e9d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -14,7 +14,7 @@ import { SessionPersistence, PersistenceCoordinator, type PersistenceBackend, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' @@ -110,14 +110,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. - /** - * The per-session init promises, exposed for white-box tests that await a - * specific session's onCreated (there is no public API to await one init). - */ - get inits(): Map> { - return this.coordinator.inits - } - // --- PersistenceBackend hooks (the SQLite storage primitives) --- /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index e3429e5835..bae79b0a53 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -14,6 +14,19 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +async function expectParallelFlushError(promise: Promise, message: RegExp): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(AggregateError) + const [cause] = (error as AggregateError).errors as unknown[] + expect(cause).toBeInstanceOf(Error) + expect((cause as Error).message).toMatch(message) + return + } + throw new Error('expected parallel flush to reject') +} + async function freshDbPath(): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-')) dirs.push(dir) @@ -405,7 +418,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { }, { inject: ['sessions'] })) session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } }) await ctx.plugin(SessionPersistenceSqlite, { path }) - await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/) + await expectParallelFlushError(ctx.parallel('session/flush', session), /id collision/) await ctx.fiber.dispose() }) }) diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index ed6c80dfd9..91eef09007 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -23,10 +23,10 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index cbdb9ba25f..4a2fed8d34 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,7 +8,6 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { seedCoversPrefix } from './index.ts' /** * A stored session's header, valid contiguous event prefix, and optional opaque @@ -110,6 +109,15 @@ async function settledErrors(promises: Iterable>): Promise { + const seedEvent = seed[index] + return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event) + }) +} + /** * Owns the backend-agnostic session write-path orchestration. A backend * constructs one (`new PersistenceCoordinator(ctx, this)`), implements @@ -134,10 +142,10 @@ export class PersistenceCoordinator { private chains = new Map>() /** * Init promises keyed by live session object, preventing an id-reusing - * replacement from inheriting stale initialization. Readonly access supports - * backend white-box tests. + * replacement from inheriting stale initialization. Flush is the public + * observation boundary; callers do not inspect this bookkeeping directly. */ - readonly inits = new Map>() + private inits = new Map>() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 87bbdde129..3e6c9c21ed 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -6,7 +6,6 @@ */ import { Context, Service } from 'cordis' -import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' // Re-export the metadata vocabulary so consumers import it from the seam. @@ -22,35 +21,6 @@ declare module 'cordis' { } } -/** - * Check whether a live seed exactly reproduces a durable prefix, including full - * payloads. This distinguishes resume/HMR rebinding from an id collision. - * @param seed - the live session's creation-time event snapshot. - * @param prefix - the persisted prefix the seed must reproduce. - * @returns `true` when the prefix fits within the seed and every event matches by JSON text. - */ -export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { - return prefix.length <= seed.length - && prefix.every((event, index) => { - const seedEvent = seed[index] - return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event) - }) -} - -/** - * Reject a batch that is not wholly losslessly JSON-serializable. Live session - * appends already enforce this; persistence append paths also accept replay or - * direct batches that may bypass a live session instance. Validation uses the - * same one-pass materializer as the coordinator, so getters are read once. - * @param events - the complete event batch to validate. - */ -export function assertSerializable(events: readonly SessionEvent[]): void { - const snapshot = snapshotJsonValue(events) - if (snapshot === undefined) { - throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data') - } -} - /** * Durable append-only session storage. Implementations preserve contiguous, * losslessly JSON-serializable events; {@link append} resolves only after diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index ffe5c7f6bd..f6692b9175 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -13,7 +13,6 @@ import { describe, expect, it } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import type { SessionPersistence } from '../src/index.ts' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -39,11 +38,6 @@ export interface CoordinatorFixture { const WORK = '/w' const OTHER = '/other' -/** The per-session init map a backend exposes for white-box init awaits. */ -function inits(persistence: SessionPersistence): Map> { - return (persistence as unknown as { inits: Map> }).inits -} - /** Append a whole event log to a live session, event by event (drives session/event). */ function send(session: Session, events: readonly SessionEvent[]): void { appendLog(session, events) @@ -168,7 +162,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const seed = oneTurnLog() // A fork: a brand-new id whose seed came from elsewhere. const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } }) - await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed + await ctx.sessions.flush(forked) // onCreated persisted the seed const loaded = await ctx.sessionPersistence.load(SessionId('forked')) expect(loaded.events).toEqual(seed) // A flush with no NEW events must not double-write. @@ -197,7 +191,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } }) - await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt + await second.ctx.sessions.flush(s2) // let onCreated adopt s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) await second.ctx.parallel('session/flush', s2) @@ -370,7 +364,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< try { const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await expect(inits(second.ctx.sessionPersistence).get(s2)) + await expect(second.ctx.sessions.flush(s2)) .rejects.toThrow(/already has a persisted log|id collision/) } finally { await second.fiber.dispose() @@ -388,14 +382,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state + await ctx.sessions.flush(firstSession) // register the lazy state await firstFiber.dispose() // disposed before any append → never materialized let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', reuse) @@ -415,7 +409,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await inits(ctx.sessionPersistence).get(first) + await ctx.sessions.flush(first) // Append a turn but do NOT flush — events sit in the write-behind buffer. first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -425,7 +419,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/) + await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/already bound to a different live session/) } finally { await fiber.dispose() await fix.cleanup() @@ -462,7 +456,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session with that id arrives and claims it (cursor 0 matches // trivially), persisting its seed. const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined() + await expect(ctx.sessions.flush(live)).resolves.toBeUndefined() const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) } finally { @@ -487,7 +481,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.plugin(Object.assign((inner: Context) => { fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } }) }, { inject: ['sessions'] })) - await expect(inits(ctx.sessionPersistence).get(fresh)) + await expect(ctx.sessions.flush(fresh)) .rejects.toThrow(/do not match this live session|already has a persisted log|id collision/) } finally { await fiber.dispose() @@ -511,7 +505,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, ], meta: { cwd: WORK } }) - await inits(ctx.sessionPersistence).get(cont) + await ctx.sessions.flush(cont) const loaded = await ctx.sessionPersistence.load(SessionId('claim')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) } finally { @@ -531,7 +525,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // cwd scope is the fence (without it, WORK events would append under the // OTHER header). Rejected as a collision. const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() await fix.cleanup() @@ -549,7 +543,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session whose SEED matches the loaded prefix but whose cwd is // WORK must still be rejected — the cwd guard runs before the seed check. const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } }) - await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() await fix.cleanup() @@ -565,7 +559,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< // A live session reusing the id but WITH cwd WORK is a cwd mismatch // (undefined vs WORK) and must be rejected. const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } }) - await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/) + await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/) } finally { await fiber.dispose() await fix.cleanup() diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 13943d35a7..a937a39da7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { - SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix, + SessionPersistence, PersistenceCoordinator, type PersistenceBackend, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' @@ -53,11 +53,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.load(id) } - /** White-box accessor: await a specific session's onCreated init. */ - get inits(): Map> { - return this.coordinator.inits - } - // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. Ids are @@ -157,38 +152,3 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) }) - -describe('shared persistence helpers', () => { - it('accepts a seed that reproduces the persisted prefix exactly', () => { - const log = oneTurnLog() - expect(seedCoversPrefix(log, log.slice(0, 3))).toBe(true) - expect(seedCoversPrefix(log, [])).toBe(true) - }) - - it('rejects a prefix longer than the seed', () => { - const log = oneTurnLog() - expect(seedCoversPrefix(log.slice(0, 2), log)).toBe(false) - }) - - it('rejects a same-envelope event with mutated data', () => { - const log = oneTurnLog() - const tampered = structuredClone(log) - const event = tampered[1]! - tampered[1] = { - ...event, - data: { ...event.data, content: [{ type: 'text', text: 'tampered' }] }, - } as SessionEvent - expect(seedCoversPrefix(tampered, log.slice(0, 2))).toBe(false) - }) - - it('accepts JSON-serializable event data', () => { - expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow() - }) - - it('rejects a batch containing non-JSON-serializable event data', () => { - const bad = [ - { type: 'user/message', seq: 0, time: 1, data: { content: 1n } }, - ] as unknown as SessionEvent[] - expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/) - }) -}) diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 9f78d4f1db..e87327de13 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -39,6 +39,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index dcacc5960a..d1ca775a26 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0", @@ -33,6 +33,6 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index b303e16bed..c025de6ee9 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -22,12 +22,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 7523f562a5..f736fc8d0f 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -119,23 +119,6 @@ declare module 'cordis' { interface Context { skills: SkillService } - - interface Events { - /** - * A skill provider became resolvable in the `ctx.skills` registry. - * Consumers can observe this instead of depending on Cordis plugin load - * order, which is concurrent for sibling plugins. - * @param provider - the provider that just registered. - * @mode emit - */ - 'skill/provider-added'(provider: SkillProvider): void - /** - * A skill provider left the registry because its plugin fiber was disposed. - * @param name - the registry name that no longer resolves. - * @mode emit - */ - 'skill/provider-removed'(name: string): void - } } interface IndexedCandidate { @@ -191,19 +174,16 @@ export class SkillService extends Service { throw new Error(`a skill provider named "${name}" is already registered`) } const providers = this.providers - const ctx = this.ctx const order = this.nextProviderOrder const invalidateCache = (): void => { this.invalidateCache() } this.nextProviderOrder += 1 - const dispose = ctx.effect(function* () { + const dispose = this.ctx.effect(function* () { providers.set(name, { provider, order }) invalidateCache() yield () => { providers.delete(name) invalidateCache() - ctx.emit('skill/provider-removed', name) } - ctx.emit('skill/provider-added', provider) }, 'skills.registerProvider()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index bf7a77a49e..3d6ddc6b7e 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -38,6 +38,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index e73d861a79..5093e3df40 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "@agentclientprotocol/sdk": "0.25.1", @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 7b1c40c4f3..8794884518 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 4e6b72533a..aa80dcac3e 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -28,7 +28,7 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -39,6 +39,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 087371ded2..f2500a4a56 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -43,7 +43,7 @@ "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json index 68f525dd8e..5f17459276 100644 --- a/packages/subagent/subagent-subprocess/package.json +++ b/packages/subagent/subagent-subprocess/package.json @@ -22,9 +22,9 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index eb0dbf8da0..0b09033fd3 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -26,13 +26,13 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4635de9697..8657e7d06e 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -77,6 +77,7 @@ declare module 'cordis' { * parent-scoped listener observes only its own delegations. Paired with * `subagent/end`. * @param info - the provider and ready child identity. + * @dshScopeScan unsupported * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void @@ -85,6 +86,7 @@ declare module 'cordis' { * parent carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. * @param info - the run identity and terminal outcome. + * @dshScopeScan unsupported * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 254c9e2928..88d4f8e4f3 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -38,7 +38,7 @@ "@deepseek-ai/dsh-subagent-mock": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/README.md b/packages/support/README.md index b6559a1a79..e9c72a558d 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -6,7 +6,8 @@ Packages that exist to serve development, testing, and the examples rather than |---|---|---| | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | +| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 7835deb432..644ec2853d 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -5,8 +5,8 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Three layers, importable separately: - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). -- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. +- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: @@ -35,9 +35,9 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized composed prompt in generated `system-prompt.golden.md` and the initial schemas plus schema deltas in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. -Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, and prompt snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). `suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 363bc86e25..d206b08161 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -27,9 +27,9 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 5afb7ecf66..bdf8cccaf7 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -20,6 +20,7 @@ export { normalizeStdout, scrubRequestHeaders, scrubSystemPrompts, + scrubToolSchemas, type NormalizeContext, } from './normalize.ts' export { diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 81ee42e9e3..e3f32792ab 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -1,8 +1,8 @@ /** * Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids, * timestamps, and hook duration while preserving deterministic event sequence numbers. - * Request-header scrubbers stay separate so one scenario per header class can pin tools and a - * readable prompt while other fixtures omit duplicated header bulk. + * Request-header scrubbers stay composable so one scenario per header class can pin prompt and + * tool-schema sidecars while retaining any model-visible prefix in the session log. * @module @deepseek-ai/dsh-acp-snapshot/normalize */ @@ -123,7 +123,21 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri * @returns The JSONL with system-prompt content tokenized. */ export function scrubSystemPrompts(rawLog: string): string { - return scrubHeaderContent(rawLog, false) + return scrubHeaderContent(rawLog, { system: true }) +} + +/** + * Replace tool schemas in request headers and header deltas with `{{tools}}` + * tokens while retaining field presence, tool names, and delta structure. + * System prompts and session-prefix messages stay verbatim so pinning fixtures + * can move only schema bulk into their dedicated JSON sidecar. Lines without a + * tool payload pass through byte-for-byte; the transform is idempotent. + * + * @param rawLog The raw session `.jsonl` content. + * @returns The JSONL with tool-schema content tokenized. + */ +export function scrubToolSchemas(rawLog: string): string { + return scrubHeaderContent(rawLog, { tools: true }) } /** @@ -138,11 +152,18 @@ export function scrubSystemPrompts(rawLog: string): string { * @returns The JSONL with all header bulk tokenized, other lines byte-identical. */ export function scrubRequestHeaders(rawLog: string): string { - return scrubHeaderContent(rawLog, true) + return scrubHeaderContent(rawLog, { system: true, tools: true, prefix: true }) } -/** Transform header content, optionally including tool schemas and the session prefix. */ -function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): string { +/** Which independent request-header payloads a scrubber replaces. */ +interface HeaderScrubOptions { + system?: boolean + tools?: boolean + prefix?: boolean +} + +/** Transform the selected request-header payloads. */ +function scrubHeaderContent(rawLog: string, options: HeaderScrubOptions): string { const lines = rawLog.split('\n') const out = lines.map((line) => { if (line.trim().length === 0) return line @@ -153,9 +174,9 @@ function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): strin const header = data.header as Record | null | undefined if (header === null || typeof header !== 'object') return line let touched = false - if ('system' in header) { header.system = SYSTEM; touched = true } - if (scrubToolsAndPrefix && 'tools' in header) { header.tools = TOOLS; touched = true } - if (scrubToolsAndPrefix && Array.isArray(header.messagePrefix)) { + if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true } + if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true } + if (options.prefix === true && Array.isArray(header.messagePrefix)) { header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX) touched = true } @@ -164,16 +185,16 @@ function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): strin if (record.type === 'request/header-delta') { let touched = false const system = data.system as Record | null | undefined - if (system !== null && typeof system === 'object' && Array.isArray(system.insert)) { + if (options.system === true && system !== null && typeof system === 'object' && Array.isArray(system.insert)) { system.insert = system.insert.map(() => SYSTEM) touched = true } const tools = data.tools as Record | null | undefined - if (scrubToolsAndPrefix && tools !== null && typeof tools === 'object') { + if (options.tools === true && tools !== null && typeof tools === 'object') { if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true } if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true } } - if (scrubToolsAndPrefix && Array.isArray(data.messagePrefix)) { + if (options.prefix === true && Array.isArray(data.messagePrefix)) { data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX) touched = true } diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 8930b03d2f..dc1675eac0 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -4,8 +4,8 @@ * output. Record mode refreshes reproducible model scenarios from the live API, while refresh * mode replays committed scripts and rewrites derived artifacts without a key. * - * Exactly one scenario per header-composition class pins tool schemas in JSONL and the system - * prompt in Markdown. Every live header is checked against that pin, so session-dependent + * Exactly one scenario per header-composition class pins the system prompt and tool schemas in + * dedicated sidecars. Every live header is checked against that pin, so session-dependent * composition must declare a separate class instead of escaping coverage. * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -21,11 +21,18 @@ import { normalizeStdout, scrubRequestHeaders, scrubSystemPrompts, + scrubToolSchemas, } from './normalize.ts' /** The readable system-prompt snapshot beside each header-pinning fixture. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.golden.md' +/** The structured tool-schema snapshot beside each header-pinning fixture. */ +const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.golden.json' + +/** Stable session-log token standing in for the sidecar's initial schemas. */ +const TOOLS_TOKEN = '{{tools}}' + /** A snapshot scenario and how its fixtures are produced. */ export interface Scenario { name: string @@ -68,8 +75,8 @@ export interface Scenario { */ childSessions?: number /** - * Whether this scenario is its header class's sole request-header pin. Its Markdown file owns - * the prompt, its JSONL keeps tool schemas, and every classmate is checked for equality. + * Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own + * the prompt and tool schemas, while every classmate is checked for equality. */ pinsHeader?: boolean /** @@ -184,6 +191,98 @@ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext): }) } +/** + * The normalized tool-schema arrays carried by request headers in a session + * JSONL, in log order. Headers without an array-valued tools field are omitted + * so callers can assert one schema set per header explicitly. + * + * @param rawLog The session `.jsonl` content to inspect. + * @param ctx The volatile values of the run that produced it. + * @returns The normalized initial tool-schema arrays, in header order. + */ +export function normalizedToolSchemas(rawLog: string, ctx: NormalizeContext): unknown[][] { + return normalizedHeaders(rawLog, ctx).flatMap((header) => { + if (header === null || typeof header !== 'object') return [] + const tools = (header as { tools?: unknown }).tools + return Array.isArray(tools) ? [tools] : [] + }) +} + +/** + * Extract normalized tool-schema edits from request-header deltas in log order. + * Deltas without an object-valued tools edit are omitted; their remaining + * structure stays pinned in the session JSONL. + * + * @param rawLog The session `.jsonl` content to inspect. + * @param ctx The volatile values of the run that produced it. + * @returns The normalized tool-schema edits, in event order. + */ +export function normalizedToolSchemaDeltas(rawLog: string, ctx: NormalizeContext): unknown[] { + return normalizeSessionLog(rawLog, ctx) + .split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as { type?: unknown; data?: { tools?: unknown } }) + .filter(record => record.type === 'request/header-delta') + .flatMap((record) => { + const tools = record.data?.tools + return tools !== null && typeof tools === 'object' && !Array.isArray(tools) ? [tools] : [] + }) +} + +/** The structured contents of a tool-schema sidecar. */ +export interface ToolSchemasSnapshot { + /** The complete tool schemas from the pinned request header. */ + initial: unknown[] + /** Complete tool-schema edits from subsequent request-header deltas. */ + deltas: unknown[] +} + +/** + * Render tool schemas and later schema edits as canonical, readable JSON. + * + * @param initial The pinned request header's complete tool schemas. + * @param deltas Complete tool-schema edits from request-header deltas. + * @returns A pretty-printed JSON snapshot ending in one newline. + */ +export function formatToolSchemasSnapshot(initial: readonly unknown[], deltas: readonly unknown[] = []): string { + return `${JSON.stringify({ initial, deltas }, null, 2)}\n` +} + +/** + * Parse and validate the stable top-level shape of a tool-schema sidecar. + * + * @param snapshot The JSON sidecar text. + * @returns Its initial schemas and schema deltas. + */ +export function parseToolSchemasSnapshot(snapshot: string): ToolSchemasSnapshot { + const parsed = JSON.parse(snapshot) as unknown + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('acp-snapshot: tool-schema snapshot must be an object') + } + const { initial, deltas } = parsed as { initial?: unknown; deltas?: unknown } + if (!Array.isArray(initial) || !Array.isArray(deltas)) { + throw new Error('acp-snapshot: tool-schema snapshot must carry array-valued initial and deltas fields') + } + return { initial, deltas } +} + +/** + * Restore a sidecar's initial schemas into a tokenized pinned header. + * + * @param header The parsed request header carrying `tools: "{{tools}}"`. + * @param snapshot The parsed tool-schema sidecar. + * @returns A copy of the header with its complete initial schemas restored. + */ +export function restorePinnedToolSchemas(header: unknown, snapshot: ToolSchemasSnapshot): unknown { + if (header === null || typeof header !== 'object' || Array.isArray(header)) { + throw new Error('acp-snapshot: pinned request header must be an object') + } + if ((header as { tools?: unknown }).tools !== TOOLS_TOKEN) { + throw new Error(`acp-snapshot: pinned request header tools must equal ${TOOLS_TOKEN}`) + } + return { ...header, tools: snapshot.initial } +} + /** One normalized system-prompt edit carried by a `request/header-delta`. */ export interface SystemPromptDeltaSnapshot { /** How many leading lines remain from the prior prompt. */ @@ -274,6 +373,27 @@ function parseJsonlRecords(text: string): Record[] { .map(line => JSON.parse(line) as Record) } +/** + * Find tool calls whose structured result reports `UNKNOWN_TOOL`. + * + * Snapshot refresh must not turn a missing registration into accepted behavior; + * intentional unknown-tool behavior belongs in a focused unit or e2e test. + * + * @param rawLog The session JSONL to inspect. + * @returns The failing call ids in log order, using a diagnostic placeholder when absent. + */ +export function unknownToolCallIds(rawLog: string): string[] { + return parseJsonlRecords(rawLog).flatMap((record) => { + if (record.type !== 'tool/result') return [] + const data = record.data + if (data === null || typeof data !== 'object') return [] + const { callId, error } = data as { callId?: unknown; error?: unknown } + if (error === null || typeof error !== 'object') return [] + if ((error as { code?: unknown }).code !== 'UNKNOWN_TOOL') return [] + return [typeof callId === 'string' ? callId : ''] + }) +} + /** * Build the cross-log id/cwd replacements used by refresh write-back. * @@ -401,6 +521,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, }) + for (const log of result.sessionLogs) { + expect(unknownToolCallIds(log.content), `session ${log.id}: snapshot scenarios must not accept UNKNOWN_TOOL`) + .toEqual([]) + } + // Scrub every volatile id the run produced: the ACP server-issued session id plus every // harvested log's recorded id (a subagent child id never surfaces over ACP, but it // appears in the child's own log header). @@ -413,9 +538,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } // Record writes live model fixtures; keyless refresh writes every comparable replayed - // fixture. Pins keep tools but all JSONL files scrub prompt text. + // fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars. const scrub = scenario.pinsHeader === true - ? scrubSystemPrompts + ? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log)) : scrubRequestHeaders const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] const existingFixtures = REFRESHING @@ -452,6 +577,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { normalizedSystemPromptDeltas(primary.content, ctx), ) await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot) + + const schemaSets = result.sessionLogs.flatMap(log => normalizedToolSchemas(log.content, ctx)) + expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0) + const initialSchemaSnapshot = formatToolSchemasSnapshot(schemaSets[0] as unknown[]) + for (const schemas of schemaSets) { + expect(formatToolSchemasSnapshot(schemas), 'the pinning run produced divergent tool schemas') + .toEqual(initialSchemaSnapshot) + } + await writeFile(join(dir, TOOL_SCHEMAS_SNAPSHOT), formatToolSchemasSnapshot( + schemaSets[0] as unknown[], + normalizedToolSchemaDeltas(primary.content, ctx), + )) } } @@ -475,7 +612,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } // Header-uniformity guard: every live header in a class must equal the class pin split - // across its JSONL header (system token + real tools) and readable Markdown prompt. + // across tokenized JSONL plus readable prompt and structured schema sidecars. /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */ const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario const pinningDir = join(snapshotsDir, pinningScenario.name) @@ -483,8 +620,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture)) const promptSnapshot = await readFile(join(pinningDir, SYSTEM_PROMPT_SNAPSHOT), 'utf8') const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot) + const toolSchemasSnapshot = await readFile(join(pinningDir, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`) .toBe(1) + const pinnedHeader = restorePinnedToolSchemas(pinned[0], toolSchemas) for (const [logIndex, log] of result.sessionLogs.entries()) { const expectedDeltas = scenario.pinsHeader === true && logIndex === 0 ? scenario.expectedHeaderDeltas ?? 0 @@ -493,11 +633,14 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(expectedDeltas) const headers = normalizedHeaders(scrubSystemPrompts(log.content), ctx) const prompts = normalizedSystemPrompts(log.content, ctx) + const schemaSets = normalizedToolSchemas(log.content, ctx) expect(prompts.length, `session ${log.id}: every request/header must carry a string system prompt`) .toBe(headers.length) + expect(schemaSets.length, `session ${log.id}: every request/header must carry an array-valued tools field`) + .toBe(headers.length) for (const [k, header] of headers.entries()) { expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) - .toEqual(pinned[0]) + .toEqual(pinnedHeader) expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(initialPromptSnapshot) } @@ -507,6 +650,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { normalizedSystemPromptDeltas(log.content, ctx), ), `session ${log.id}: system-prompt deltas diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(promptSnapshot) + expect(formatToolSchemasSnapshot( + schemaSets[0] as unknown[], + normalizedToolSchemaDeltas(log.content, ctx), + ), `session ${log.id}: tool-schema deltas diverged from ${pinningScenario.name}/${TOOL_SCHEMAS_SNAPSHOT}`) + .toEqual(toolSchemasSnapshot) } } }) @@ -535,6 +683,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(overridden === true) expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``) .toBe(pinsHeader === true) + expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``) + .toBe(pinsHeader === true) // A nested-agent scenario ships one child fixture per recorded subagent // session (`session.1.jsonl` …), the replay source for that child session. for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { @@ -558,7 +708,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } }) - it('every pinning fixture carries one request/header, one readable prompt, and its declared deltas', async () => { + it('every pinning fixture carries one tokenized request/header, two sidecars, and its declared deltas', async () => { // The live uniformity guard runs only in NON-pinning scenarios, so a class made of just // its pinning scenario would otherwise accept a re-recorded pin with several headers or // an undeclared mid-run header-delta — shapes the pin design cannot represent. @@ -566,18 +716,24 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8') const headers = normalizedHeaders(fixture, fixtureContext(fixture)) const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8') + const toolSchemasSnapshot = await readFile(join(snapshotsDir, scenario.name, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) expect(headers.length, `${scenario.name}: a pinning fixture must carry exactly one request/header`).toBe(1) + expect(() => restorePinnedToolSchemas(headers[0], toolSchemas), `${scenario.name}: tools must use the sidecar token`) + .not.toThrow() expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0) expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true) + expect(toolSchemasSnapshot, `${scenario.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`) + .toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.deltas)) expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared request/header-deltas`) .toBe(scenario.expectedHeaderDeltas ?? 0) } }) - it('every committed JSONL omits system prompts and only pinning fixtures keep other header bulk', async () => { - // System prompts always live in the readable Markdown artifact. Header - // pins keep tool schemas/prefixes in JSONL; every other fixture tokenizes - // all header bulk. Fixed-point checks make both storage rules fail loud. + it('every committed JSONL has valid tool results and canonical header storage', async () => { + // Prompts and schemas always leave JSONL. Header pins retain prefixes; + // every other fixture tokenizes those too. Fixed-point checks make both + // storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) const files = [ @@ -586,12 +742,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ] for (const file of files) { const fixture = await readFile(join(dir, file), 'utf8') + expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`) + .toEqual([]) expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`) .toEqual(fixture) - if (scenario.pinsHeader === true) { - expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} must pin the non-system header content`) - .not.toEqual(fixture) - } else { + expect(scrubToolSchemas(fixture), `${scenario.name}/${file} carries unscrubbed tool schemas`) + .toEqual(fixture) + if (scenario.pinsHeader !== true) { expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} carries unscrubbed header content`) .toEqual(fixture) } diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl index d496dbdb1d..e9dd3fb16c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/session.jsonl @@ -1,2 +1,2 @@ {"type":"session","id":"ccdc749f-56f3-4267-9750-598b5c60b7b2","createdAt":600,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-nOQ4Gy"} -{"type":"request/header","seq":0,"time":4,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}} +{"type":"request/header","seq":0,"time":4,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.golden.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.golden.json new file mode 100644 index 0000000000..f96325d18b --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.golden.json @@ -0,0 +1,12 @@ +{ + "initial": [ + { + "name": "t1", + "description": "D1", + "parameters": { + "type": "object" + } + } + ], + "deltas": [] +} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl index 0215afd538..92947ccb9b 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl @@ -1,4 +1,4 @@ {"type":"session","id":"12121212-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/pin-cwd"} -{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}} +{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/header-delta","seq":1,"time":7,"data":{"system":{"keepStart":1,"keepEnd":0,"insert":["{{system}}"]}}} {"type":"turn/start","seq":2,"time":7,"data":{"turn":1}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.golden.json b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.golden.json new file mode 100644 index 0000000000..f96325d18b --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.golden.json @@ -0,0 +1,12 @@ +{ + "initial": [ + { + "name": "t1", + "description": "D1", + "parameters": { + "type": "object" + } + } + ], + "deltas": [] +} diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index bdde120a76..d6c95045f6 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -5,6 +5,7 @@ import { normalizeStdout, scrubRequestHeaders, scrubSystemPrompts, + scrubToolSchemas, } from '../src/normalize.ts' /** @@ -306,3 +307,45 @@ describe('scrubSystemPrompts', () => { expect(scrubSystemPrompts(out)).toBe(out) }) }) + +describe('scrubToolSchemas', () => { + it('scrubs only tool-schema payloads while keeping prompts and prefixes verbatim', () => { + const header = JSON.stringify({ + type: 'request/header', seq: 1, time: 2, + data: { + header: { + system: 'full prompt', + tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }], + messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }], + }, + reason: 'initial', + }, + }) + const delta = JSON.stringify({ + type: 'request/header-delta', seq: 2, time: 3, + data: { + system: { keepStart: 1, keepEnd: 2, insert: ['new prompt line'] }, + tools: { added: [{ name: 'grep', description: 'new schema' }], changed: [{ name: 'read', description: 'changed schema' }] }, + messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }], + }, + }) + const systemOnly = JSON.stringify({ + type: 'request/header', seq: 3, time: 4, + data: { header: { system: 'prompt only' }, reason: 'resume' }, + }) + + const out = scrubToolSchemas(`${header}\n${delta}\n${systemOnly}\n`) + expect(out).toContain('"tools":"{{tools}}"') + expect(out).toContain('"added":[{"name":"grep","description":"{{tools}}"}]') + expect(out).toContain('"changed":[{"name":"read","description":"{{tools}}"}]') + expect(out).not.toContain('full schema') + expect(out).not.toContain('new schema') + expect(out).not.toContain('changed schema') + expect(out).toContain('full prompt') + expect(out).toContain('new prompt line') + expect(out).toContain('full prefix') + expect(out).toContain('changed prefix') + expect(out.split('\n')[2]).toBe(systemOnly) + expect(scrubToolSchemas(out)).toBe(out) + }) +}) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index c76dd32a92..204647d8c9 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -9,12 +9,18 @@ import { childFixturePaths, fixtureContext, formatSystemPromptSnapshot, + formatToolSchemasSnapshot, headerDeltaCount, normalizedHeaders, normalizedSystemPromptDeltas, normalizedSystemPrompts, + normalizedToolSchemaDeltas, + normalizedToolSchemas, + parseToolSchemasSnapshot, refreshFixtureReplacements, + restorePinnedToolSchemas, stabilizeRefreshLog, + unknownToolCallIds, } from '../src/suite.ts' /** @@ -70,6 +76,7 @@ afterAll(async () => { function staleRefreshFixtures(dir: string): void { writeFileSync(join(dir, 'plain-turn', 'stdout.golden.jsonl'), 'stale stdout\n') writeFileSync(join(dir, 'pin-turn', 'system-prompt.golden.md'), 'STALE PROMPT\n') + writeFileSync(join(dir, 'pin-turn', 'tool-schemas.golden.json'), '{"initial":[{"name":"stale"}],"deltas":[]}\n') const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json') const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record @@ -125,6 +132,9 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { 'NEW PROMPT LINE', '', ].join('\n')) + const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.golden.json'), 'utf8') + expect(schemas).toContain('"description": "D1"') + expect(schemas).not.toContain('"name":"stale"') }) }) @@ -235,6 +245,40 @@ describe('normalizedSystemPrompts', () => { }) }) +describe('normalizedToolSchemas', () => { + it('extracts normalized schema arrays and omits absent or non-array fields', () => { + const log = [ + '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}', + '{"type":"request/header","seq":0,"time":9,"data":{"header":{"tools":[{"name":"read","description":"work in /w"}]}}}', + '{"type":"request/header","seq":1,"time":9,"data":{"header":{}}}', + '{"type":"request/header","seq":2,"time":9,"data":{"header":{"tools":null}}}', + '{"type":"request/header","seq":3,"time":9,"data":{"header":null}}', + '{"type":"request/header","seq":4,"time":9,"data":{"header":"invalid"}}', + '', + ].join('\n') + expect(normalizedToolSchemas(log, { sessionIds: [], cwd: '/w' })).toEqual([ + [{ name: 'read', description: 'work in {{cwd}}' }], + ]) + }) +}) + +describe('normalizedToolSchemaDeltas', () => { + it('extracts and normalizes object-valued schema edits', () => { + const log = [ + '{"type":"request/header-delta","data":{"tools":{"added":[{"name":"read","description":"work in /w"}]}}}', + '{"type":"request/header-delta","data":{"tools":null}}', + '{"type":"request/header-delta","data":{"tools":"invalid"}}', + '{"type":"request/header-delta","data":{"tools":[]}}', + '{"type":"request/header-delta","data":{"system":{"insert":[]}}}', + '{"type":"request/header","data":{"tools":{"added":[]}}}', + '', + ].join('\n') + expect(normalizedToolSchemaDeltas(log, { sessionIds: [], cwd: '/w' })).toEqual([ + { added: [{ name: 'read', description: 'work in {{cwd}}' }] }, + ]) + }) +}) + describe('normalizedSystemPromptDeltas', () => { it('extracts and normalizes well-formed system edits', () => { const log = [ @@ -269,6 +313,39 @@ describe('formatSystemPromptSnapshot', () => { }) }) +describe('tool-schema snapshots', () => { + const snapshot = { + initial: [{ name: 'read', description: 'Read a file.' }], + deltas: [{ added: [{ name: 'grep', description: 'Search files.' }] }], + } + + it('formats and parses canonical structured JSON', () => { + const formatted = formatToolSchemasSnapshot(snapshot.initial, snapshot.deltas) + expect(formatted).toBe(`${JSON.stringify(snapshot, null, 2)}\n`) + expect(parseToolSchemasSnapshot(formatted)).toEqual(snapshot) + }) + + it('rejects invalid top-level and field shapes', () => { + expect(() => parseToolSchemasSnapshot('null')).toThrow(/must be an object/) + expect(() => parseToolSchemasSnapshot('"invalid"')).toThrow(/must be an object/) + expect(() => parseToolSchemasSnapshot('[]')).toThrow(/must be an object/) + expect(() => parseToolSchemasSnapshot('{"initial":{},"deltas":[]}')).toThrow(/array-valued/) + expect(() => parseToolSchemasSnapshot('{"initial":[],"deltas":{}}')).toThrow(/array-valued/) + }) + + it('restores initial schemas into the pinned header token', () => { + expect(restorePinnedToolSchemas({ system: '{{system}}', tools: '{{tools}}' }, snapshot)) + .toEqual({ system: '{{system}}', tools: snapshot.initial }) + }) + + it('rejects invalid headers and a missing tool token', () => { + expect(() => restorePinnedToolSchemas(null, snapshot)).toThrow(/must be an object/) + expect(() => restorePinnedToolSchemas('invalid', snapshot)).toThrow(/must be an object/) + expect(() => restorePinnedToolSchemas([], snapshot)).toThrow(/must be an object/) + expect(() => restorePinnedToolSchemas({ tools: [] }, snapshot)).toThrow(/must equal/) + }) +}) + describe('headerDeltaCount', () => { it('counts request/header-delta events, ignoring blanks and other lines', () => { const delta = JSON.stringify({ type: 'request/header-delta', seq: 2, time: 9, data: {} }) @@ -278,6 +355,27 @@ describe('headerDeltaCount', () => { }) }) +describe('unknownToolCallIds', () => { + it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => { + const log = [ + '{"type":"tool/result","data":{"callId":"missing","error":{"code":"UNKNOWN_TOOL"}}}', + '{"type":"tool/result","data":{"callId":"failed","error":{"code":"EXECUTION_FAILED"}}}', + '{"type":"tool/result","data":null}', + '{"type":"tool/result","data":"invalid"}', + '{"type":"tool/result","data":{"error":null}}', + '{"type":"tool/result","data":{"error":"invalid"}}', + '{"type":"assistant/message","data":{"error":{"code":"UNKNOWN_TOOL"}}}', + '{"type":"tool/result","data":{"error":{"code":"UNKNOWN_TOOL"}}}', + '', + ].join('\n') + expect(unknownToolCallIds(log)).toEqual(['missing', '']) + }) + + it('returns no failures for ordinary tool results', () => { + expect(unknownToolCallIds('{"type":"tool/result","data":{"callId":"ok"}}\n')).toEqual([]) + }) +}) + describe('refreshFixtureReplacements', () => { it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => { const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content }) diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 3568465d18..59a425387b 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -26,13 +26,17 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f5b7828a3d..8da5429b06 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -14,6 +14,7 @@ import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' +import { scopedSubjectResolverFor } from './scoped-events.generated.ts' export const name = 'invariants' export const inject = ['sessions'] @@ -75,17 +76,6 @@ interface SessionTraceTransition { seq: number } -/** Event payload prefix for scoped seams whose first argument names its agent. */ -interface AgentSubject { - agent: Agent -} - -/** Structural subject fields used without coupling this dev plugin to owning services. */ -interface ScopedSubjectFields { - agent?: Agent - scope?: object -} - /** Assert that a step-scoped event names the currently open turn and step. */ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: number): void { if (trace.openTurn !== turn || trace.openStep !== step) { @@ -410,40 +400,12 @@ export function apply(ctx: Context): void { // (agent-scoped listeners over-hear foreign agents), and a mis-keyed one // delivers to the wrong agent's listeners. `internal/dispatch` fires // synchronously before listener delivery, so a violation throws at the - // dispatching call site. The table maps each family to how its subject is - // read from the event arguments; `null` = the subject is not recoverable - // from the arguments (session events key by the OWNING agent; subagent - // lifecycle events key by the delegating parent), so only carrier - // PRESENCE is asserted there. - const scopedSubject: Record unknown) | null> = { - 'agent/created': args => args[0], - 'agent/disposed': args => args[0], - 'agent/status': args => args[0], - 'agent/queued': args => args[0], - 'agent/session-start': args => args[0], - 'agent/pre-step': args => args[0], - 'agent/prompt-submit': args => args[0], - 'agent/request': args => args[0], - 'agent/session-prefix': args => args[0], - 'agent/step-result': args => args[0], - 'agent/turn-continuation': args => args[0], - 'agent/turn-stop': args => args[0], - 'agent/error': args => args[0], - 'approval/request': args => (args[0] as AgentSubject).agent, - 'tools/pre-execute': args => (args[0] as ScopedSubjectFields).agent, - 'tools/execute': args => (args[0] as ScopedSubjectFields).agent, - 'tools/post-execute': args => (args[0] as ScopedSubjectFields).agent, - 'tools/result': args => (args[0] as ScopedSubjectFields).agent, - 'system-prompt/assemble': args => (args[1] as ScopedSubjectFields).scope, - 'session/created': null, - 'session/disposed': null, - 'session/event': null, - 'session/flush': null, - 'subagent/start': null, - 'subagent/end': null, - } + // dispatching call site. The generated table maps each family to the unique + // payload path whose Program type matches the real scopeTarget routing key; + // `null` means the key is external to the payload, so only carrier presence + // can be asserted. ctx.on('internal/dispatch', (_mode, name, args, thisArg) => { - const subjectOf = scopedSubject[name] + const subjectOf = scopedSubjectResolverFor(name) if (subjectOf === undefined) return if (!isScopeCarrier(thisArg)) { throw new InvariantError( diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts new file mode 100644 index 0000000000..06cca6bf55 --- /dev/null +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -0,0 +1,69 @@ +/** + * Generated scoped-event routing-subject resolvers for dsh-invariants. + * Do not edit by hand; run `pnpm run gen-scoped-events`. + * + * @module @deepseek-ai/dsh-invariants/scoped-events.generated + */ + +import type { Events } from 'cordis' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type {} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-subagent' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-user-approval' + +type ScopedEventName = { + [K in keyof Events]: ThisParameterType extends Scoped ? K : never +}[keyof Events] + +type ScopedSubjectResolver = (args: readonly unknown[]) => unknown + +function adapt( + resolver: (args: Parameters) => unknown, +): ScopedSubjectResolver { + return args => resolver(args as Parameters) +} + +const scopedSubjectResolvers = Object.freeze({ + 'agent/created': adapt<'agent/created'>(args => args[0]), + 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), + 'agent/error': adapt<'agent/error'>(args => args[0]), + 'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]), + 'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]), + 'agent/queued': adapt<'agent/queued'>(args => args[0]), + 'agent/request': adapt<'agent/request'>(args => args[0]), + 'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]), + 'agent/session-start': adapt<'agent/session-start'>(args => args[0]), + 'agent/status': adapt<'agent/status'>(args => args[0]), + 'agent/step-result': adapt<'agent/step-result'>(args => args[0]), + 'agent/turn-continuation': adapt<'agent/turn-continuation'>(args => args[0]), + 'agent/turn-stop': adapt<'agent/turn-stop'>(args => args[0]), + 'approval/request': adapt<'approval/request'>(args => args[0].agent), + 'session/created': null, + 'session/disposed': null, + 'session/event': null, + 'session/flush': null, + 'subagent/end': null, + 'subagent/start': null, + 'system-prompt/assemble': adapt<'system-prompt/assemble'>(args => args[1].scope), + 'tools/execute': adapt<'tools/execute'>(args => args[0].agent), + 'tools/post-execute': adapt<'tools/post-execute'>(args => args[0].agent), + 'tools/pre-execute': adapt<'tools/pre-execute'>(args => args[0].agent), + 'tools/result': adapt<'tools/result'>(args => args[0].agent), +} as const satisfies Readonly>) + +const scopedSubjectResolverIndex: Readonly> = scopedSubjectResolvers + +/** + * Resolve the routing key named by one scoped event payload. A null + * resolver means the payload cannot expose its external routing key, so the + * invariant checks carrier presence only. + * @param event - runtime Cordis event name. + * @returns the generated subject resolver, null for presence-only, + * or undefined when the event is not scope-filtered. + */ +export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined { + return scopedSubjectResolverIndex[event] +} diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index e53b0b8e40..c451440e8e 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -834,7 +834,7 @@ describe('scoped-dispatch invariants', () => { it('accepts a matching carrier and rejects a mismatched one for EVERY agent-subject event', async () => { const ctx = await scopedCtx() - // Real Session objects: the session-start tracker WeakSet-keys them. + // Real Session objects keep the synthetic Agent handles structurally valid. const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent // One dispatch per table row keeps every subject extractor covered: the @@ -855,9 +855,9 @@ describe('scoped-dispatch invariants', () => { ['agent/error', [agent, 1, 0, new Error('x')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], - ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ callId: 'c', content: [], isError: false })]], - ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], - ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { callId: 'c', content: [], isError: false }]], + ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], + ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], + ['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]], ] for (const [event, args] of rows) { const subject = agent diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index cd17f67d7c..6c5bc479b5 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -25,6 +25,18 @@ }, { "path": "../../core/scope" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../ui/user-approval" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../subagent/subagent" } ] } diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index ce57ea18ef..403f3bda92 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -24,11 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md new file mode 100644 index 0000000000..ea197b25d0 --- /dev/null +++ b/packages/support/loader-smoke/README.md @@ -0,0 +1,17 @@ +# `@deepseek-ai/dsh-loader-smoke` + +Shared subprocess harness for keyless example smokes that boot the real stdio-agent bin and a real `cordis.yml` through the Cordis Loader. A test supplies absolute bin/config/tsconfig paths, optional environment overrides, and stdin lines; `runLoaderSmoke` owns the isolated cwd, DSH homes, tsx path resolution, 30-second process deadline, captured diagnostics, forced kill, EOF, and cleanup. + +Successful runs return stdout and stderr only after a zero exit. Non-zero exits and deadlines reject with both captured streams. `LOADER_SMOKE_TEST_TIMEOUT_MS` leaves Vitest enough room for the process-owned diagnostic timeout to fire first. + +This is support-tier test infrastructure, not product API. The consumers are the Loader-path smokes under `examples/{echo-agent,coding-agent,cordis-agent}`. + +## Model Experience + +None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request. + +## Known Limitations and Deferred Work + +- **Only the unbuilt tsx/Loader path is exercised** — built-bin artifacts remain the responsibility of their separate e2e smokes. +- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. +- **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json new file mode 100644 index 0000000000..ddba421b41 --- /dev/null +++ b/packages/support/loader-smoke/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-loader-smoke", + "description": "Shared subprocess harness for keyless real-Loader example smoke tests", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "tsx": "^4.22.4" + }, + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts new file mode 100644 index 0000000000..72839c6a05 --- /dev/null +++ b/packages/support/loader-smoke/src/index.ts @@ -0,0 +1,117 @@ +/** + * Shared subprocess harness for keyless example smokes that boot a real + * `cordis.yml` through the stdio-agent bin and Cordis Loader. + * + * @module @deepseek-ai/dsh-loader-smoke + */ + +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 +const TSX_LOADER = fileURLToPath(import.meta.resolve('tsx')) + +/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */ +export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000 + +/** Inputs that vary between real-Loader example smokes. */ +export interface LoaderSmokeOptions { + /** Human-readable example name used in failure diagnostics. */ + readonly label: string + /** Prefix for the isolated temporary process cwd. */ + readonly tempDirPrefix: string + /** Absolute stdio-agent bin path. */ + readonly binScript: string + /** Absolute real Loader config path. */ + readonly configPath: string + /** Absolute repo tsconfig path used for unbuilt workspace-package resolution. */ + readonly tsconfigPath: string + /** Environment overrides layered over the parent and isolated DSH homes. */ + readonly env?: Readonly + /** Lines written to stdin before EOF; omitted means immediate EOF. */ + readonly stdinLines?: readonly string[] + /** Process deadline override for harness tests. */ + readonly processTimeoutMs?: number +} + +/** Captured output from a Loader smoke that exited successfully. */ +export interface LoaderSmokeResult { + /** Complete stdout after clean exit. */ + readonly stdout: string + /** Complete stderr after clean exit. */ + readonly stderr: string +} + +/** + * Boot one real Loader tree from an isolated cwd, write the requested stdin + * script, close stdin, and await a clean exit. The helper owns process kill and + * temp-directory cleanup on every outcome. + * @param options - example paths, environment, stdin, and diagnostic identity. + * @returns captured stdout and stderr after a zero exit. + */ +export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { + const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) + const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS + try { + return await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--expose-internals', '--import', TSX_LOADER, options.binScript, options.configPath], + { + cwd, + env: { + ...process.env, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...options.env, + TSX_TSCONFIG_PATH: options.tsconfigPath, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + let deferredFailure: Error | undefined + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`) + child.kill('SIGKILL') + }, processTimeoutMs) + + child.once('exit', (code) => { + clearTimeout(timer) + if (deferredFailure !== undefined) { + reject(deferredFailure) + } else if (code === 0) { + resolve({ stdout, stderr }) + } else { + reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + } + }) + + // process.execPath and a just-created pipe make these OS-error paths + // impractical to induce without replacing the boundary under test. + /* v8 ignore start */ + child.once('error', (error) => { + clearTimeout(timer) + reject(new Error(`${options.label} failed to start: ${error.message}`)) + }) + child.stdin.once('error', (error) => { + deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`) + child.kill('SIGKILL') + }) + /* v8 ignore stop */ + + child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join('')) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} diff --git a/packages/support/loader-smoke/tests/fixtures/fail.ts b/packages/support/loader-smoke/tests/fixtures/fail.ts new file mode 100644 index 0000000000..98d2b44fb8 --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/fail.ts @@ -0,0 +1,4 @@ +/** Non-zero subprocess fixture for the Loader-smoke harness. */ + +console.error('fixture failed') +process.exitCode = 7 diff --git a/packages/support/loader-smoke/tests/fixtures/hang.ts b/packages/support/loader-smoke/tests/fixtures/hang.ts new file mode 100644 index 0000000000..97b68153ff --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/hang.ts @@ -0,0 +1,4 @@ +/** Deadline subprocess fixture for the Loader-smoke harness. */ + +console.log('fixture hanging') +setInterval(() => {}, 1_000) diff --git a/packages/support/loader-smoke/tests/fixtures/success.ts b/packages/support/loader-smoke/tests/fixtures/success.ts new file mode 100644 index 0000000000..fed57162e2 --- /dev/null +++ b/packages/support/loader-smoke/tests/fixtures/success.ts @@ -0,0 +1,16 @@ +/** Successful subprocess fixture for the Loader-smoke harness. */ + +let input = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk: string) => { input += chunk }) +process.stdin.on('end', () => { + console.log(JSON.stringify({ + configPath: process.argv[2], + cwd: process.cwd(), + dshHome: process.env.DSH_HOME, + agentsHome: process.env.DSH_AGENTS_HOME, + marker: process.env.LOADER_SMOKE_MARKER, + input, + })) + console.error('fixture stderr') +}) diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts new file mode 100644 index 0000000000..4cc9f878f9 --- /dev/null +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -0,0 +1,61 @@ +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const configPath = '/tmp/fixture.cordis.yml' +const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${name}.ts`, import.meta.url)) +const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '') + +describe('runLoaderSmoke', () => { + it('isolates the process, writes stdin, captures output, and removes the cwd', async () => { + const result = await runLoaderSmoke({ + label: 'success fixture', + tempDirPrefix: 'loader-smoke-success-', + binScript: fixture('success'), + configPath, + tsconfigPath, + env: { LOADER_SMOKE_MARKER: 'present' }, + stdinLines: ['one', 'two'], + }) + const output = JSON.parse(result.stdout) as { + configPath: string + cwd: string + dshHome: string + agentsHome: string + marker: string + input: string + } + expect(output).toMatchObject({ + configPath, + marker: 'present', + input: 'one\ntwo\n', + }) + expect(canonicalTempPath(output.dshHome)).toBe(`${canonicalTempPath(output.cwd)}/.dsh`) + expect(canonicalTempPath(output.agentsHome)).toBe(`${canonicalTempPath(output.cwd)}/.agents`) + expect(result.stderr).toContain('fixture stderr') + expect(existsSync(output.cwd)).toBe(false) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('rejects a non-zero exit with captured diagnostics', async () => { + await expect(runLoaderSmoke({ + label: 'failure fixture', + tempDirPrefix: 'loader-smoke-fail-', + binScript: fixture('fail'), + configPath, + tsconfigPath, + })).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed') + }) + + it('kills a process at its deadline and reports captured output', async () => { + await expect(runLoaderSmoke({ + label: 'hanging fixture', + tempDirPrefix: 'loader-smoke-hang-', + binScript: fixture('hang'), + configPath, + tsconfigPath, + processTimeoutMs: 100, + })).rejects.toThrow('hanging fixture did not exit within 0.1s.') + }) +}) diff --git a/packages/support/loader-smoke/tsconfig.json b/packages/support/loader-smoke/tsconfig.json new file mode 100644 index 0000000000..749cb0208e --- /dev/null +++ b/packages/support/loader-smoke/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [] +} diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json index 8980cc35d2..a4ed0a0c6e 100644 --- a/packages/support/subagent-mock/package.json +++ b/packages/support/subagent-mock/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/timeout/timeout-policy/package.json b/packages/timeout/timeout-policy/package.json index 9069735b86..aa351cb7a6 100644 --- a/packages/timeout/timeout-policy/package.json +++ b/packages/timeout/timeout-policy/package.json @@ -25,12 +25,12 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index 70ead41e1b..e946c2af61 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -6,7 +6,6 @@ */ import type { Context } from 'cordis' -import type { CallId } from '@deepseek-ai/dsh-llm' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -30,13 +29,11 @@ export const inject = ['tools'] * is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT} * this plugin owns, so a retry/sandbox plugin (and replay) can route on it. * - * @param callId - the timed-out call's id, carried onto the replacement result. * @param timeoutMs - the elapsed budget, rendered into the model-facing message. * @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error. */ -export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult { +function toolTimeoutResult(timeoutMs: number): ToolExecutionResult { return { - callId, content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }], isError: true, error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT }, @@ -68,7 +65,7 @@ export function apply(ctx: Context): void { // quiescence; replace whatever it returned (its own abort result) with the // structured TOOL_TIMEOUT the model sees. if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) { - return toolTimeoutResult(exec.callId, timeoutMs) + return toolTimeoutResult(timeoutMs) } return result } finally { diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index 30a7307515..bd06ed6e16 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -11,9 +11,9 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type ToolExecutionInput, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy' -import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy' +import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy' /** Mount the registry + the zero-config timeout-policy enforcer. */ async function setup() { @@ -60,7 +60,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) - expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) }) it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => { @@ -109,7 +109,6 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result).toEqual({ - callId: CallId('c1'), content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }], isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, @@ -140,16 +139,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { }) }) -describe('toolTimeoutResult', () => { - it('builds the structured TOOL_TIMEOUT result', () => { - expect(toolTimeoutResult(CallId('c9'), 250)).toEqual({ - callId: CallId('c9'), - content: [{ type: 'text', text: 'Error: tool call timed out after 250ms' }], - isError: true, - error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, - } satisfies ToolExecutionResult) - }) - +describe('timeout-policy contract', () => { it('exposes the owned code constant', () => { expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT') }) diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index f2d4344f99..9ff69d7c76 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -34,6 +34,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/README.md b/packages/ui/README.md index 02dfcfbdce..29ff591ee5 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -9,13 +9,14 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | +| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`bin` only) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index 8730102b64..63c9d3df23 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -42,7 +42,7 @@ The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the - honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`); - in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit. -Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers through its internal module loader, active only under that flag. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.) +Run it under `node --expose-internals`, or Loader's optional `node-addon-require-builtin` fallback is required, so the cordis Loader can resolve the config's bare plugin specifiers through its internal module loader. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.) All diagnostics go to **stderr** — stdout is the protocol. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 987ddb6c13..0c1a45e713 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -31,14 +31,14 @@ "license": "BSD-3-Clause", "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, "devDependencies": { @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } } diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index c6130130b9..b31082fa23 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -147,11 +147,11 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A nonexistent directory prevents even the include plugin import. Loader logs the failure and - // leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit. + // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config + // directory cannot break its import; the include plugin's own read must fail loud instead. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml') expect(code).not.toBe(0) - expect(stderr).toContain('failed to load') + expect(stderr).toContain('config file not found') }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index b0b038760b..71efc51a07 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index fdea712274..c3deaa4f48 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,11 +8,11 @@ Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md) | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | -| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | +| `boot(binName, absoluteConfigPath)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, using `node --expose-internals` or the optional `node-addon-require-builtin` fallback. the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. ## Model Experience @@ -20,6 +20,6 @@ Indirectly, through the plugin tree it loads, which determines the prompts, sche ## Known Limitations and Deferred Work -- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals`; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping. +- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json index 67b8b00dbc..1eef56ae93 100644 --- a/packages/ui/app-boot/package.json +++ b/packages/ui/app-boot/package.json @@ -23,12 +23,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", - "cordis": "^4.0.0-rc.6" + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index a519b3cf70..3521769816 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url' import { basename, dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -95,10 +96,16 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. The include uses an absolute file URL while `baseUrl` stays at - * the config directory for its relative imports. A missing fiber rejects here; - * a later init rejection is handled by {@link installFailLoud}. Built bins need - * `--expose-internals` for bare plugin specifiers; relative specifiers do not. + * tree settles. Entry names load through the Loader's internal module loader + * against `baseUrl` (the config directory), which may live outside + * `node_modules` reach and, unbuilt, cannot load vendored source; the + * bootstrap include is therefore statically imported and mounted as the + * `cordis:include` builtin, loading through the ambient module pipeline + * (vite/tsx/plain ESM) while the included tree's own specifiers stay + * config-relative. A missing fiber rejects here; a later init rejection is + * handled by {@link installFailLoud}. Built bins need `--expose-internals` or + * the Loader's native fallback for bare plugin specifiers; relative specifiers + * do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -108,8 +115,9 @@ export async function boot(binName: string, absoluteConfigPath: string): Promise const ctx = new Context() ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) + ctx.loader.builtins.include = Include await ctx.loader.create({ - name: '@cordisjs/plugin-include', + name: 'cordis:include', config: { path: pathToFileURL(absoluteConfigPath).href }, }) await ctx.loader.await() diff --git a/packages/ui/jsonrpc-agent/package.json b/packages/ui/jsonrpc-agent/package.json index bef09ad15f..1919a7336a 100644 --- a/packages/ui/jsonrpc-agent/package.json +++ b/packages/ui/jsonrpc-agent/package.json @@ -33,9 +33,9 @@ "@deepseek-ai/dsh-app-boot": "workspace:^" }, "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index 94355fe570..be98f173de 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -30,7 +30,7 @@ "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index b6833791e4..e38e5a7bf1 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -36,6 +36,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 8cd97f5cc7..45e09c8dd6 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | +| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. @@ -38,7 +38,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s ## The bin -`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way. +`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. ## Example leaf `cordis.yml` diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index fcd432a9fd..5861ae2b12 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -31,18 +31,19 @@ "license": "BSD-3-Clause", "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-stdio": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" }, "devDependencies": { @@ -55,11 +56,12 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-stdio": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "schemastery": "^3.17.0" } } diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index cd8167d658..fb125cec56 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,8 +1,8 @@ /** * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the - * coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the - * in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent - * the UI drives. + * coupled front-door cluster a terminal chat needs — a console logger, the independently + * packaged readline UI, JSONL session persistence, the user-interaction seam with its + * `ask_user_question` tool, and a pre-created `main` agent the UI drives. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -19,7 +19,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiStdio from './stdio-chat.ts' +import * as uiStdio from '@deepseek-ai/dsh-stdio' export const name = 'stdio-agent' diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index ddd7ca454e..0f791782e8 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -24,6 +24,7 @@ const dshPackages = [ 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', + 'ui/stdio', 'ui/tool-ask-user', 'ui/user-interaction', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', @@ -146,12 +147,12 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and - // boot's settled-entry guard must turn that state into a clear non-zero failure. + // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config + // directory cannot break its import; the include plugin's own read must fail loud instead. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') expect(code).not.toBe(0) - expect(stderr).toContain('failed to load') + expect(stderr).toContain('config file not found') }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 6f30c1558e..b0bfa760c3 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../user-interaction" }, + { + "path": "../stdio" + }, { "path": "../tool-ask-user" }, diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md new file mode 100644 index 0000000000..b7d320880d --- /dev/null +++ b/packages/ui/stdio/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-stdio + +The terminal readline front door for DeepSeek Harness agents. It reads prompts from stdin, sends or steers them through `ctx.agents`, renders the durable `session/event` transcript to stdout, and answers `ctx.userInteraction` requests in the same terminal. + +This package owns the terminal channel only. It injects `agents` and `userInteraction`, then drives an agent created or resumed by app or developer code. The agent spine, agent lifecycle, console logger, and model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `welcome` | `ready.` | Banner printed before the first prompt | +| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | + +The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. + +```yaml +- id: stdio + name: '@deepseek-ai/dsh-stdio' + config: + welcome: 'agent REPL ready. Give it a coding task.' + agent: main +``` + +## Model Experience + +### Readline prompt input + +**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. + +**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. + +### Terminal user-interaction answers + +**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. + +**Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. + +## Known Limitations and Deferred Work + +- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label. +- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. +- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json new file mode 100644 index 0000000000..3b00dc6625 --- /dev/null +++ b/packages/ui/stdio/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-stdio", + "description": "Terminal readline front door for driving and rendering DeepSeek Harness agents over stdio", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio/src/index.ts similarity index 92% rename from packages/ui/stdio-agent/src/stdio-chat.ts rename to packages/ui/stdio/src/index.ts index 60aba12026..1e665381ce 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio/src/index.ts @@ -2,7 +2,11 @@ * The stdio app's readline UI: reads lines from stdin into `agent.send()` or * `steer()`, renders the durable event stream to stdout, and exits piped input * only after submitted work reaches idle. - * @module @deepseek-ai/dsh-stdio-agent/stdio-chat + * + * This package is the independently composable stdio front door. It establishes + * the terminal channel and drives an agent created or resumed by app or + * developer code. + * @module @deepseek-ai/dsh-stdio */ import { createInterface } from 'node:readline' @@ -26,8 +30,6 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - // TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the - // precreated `main` agent; remove configurability and its config-only test. /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ agent?: string } @@ -350,16 +352,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }, 'ui-stdio') } +/** + * Open the terminal channel once its configured agent exists. Generated stdio + * projects boot the Cordis tree first and create or resume the agent from + * developer code immediately afterward, so stdin must remain untouched until + * the matching `agent/created` notification arrives. + * @param ctx - the context supplying the agent registry and event stream. + * @param config - presentation and target-agent configuration. + * @param runtime - process-I/O seam. + */ +export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { + const agentId = AgentId(config.agent ?? 'main') + if (ctx.agents.get(agentId) !== undefined) { + createStdioChat(ctx, config, runtime) + return + } + const dispose = ctx.on('agent/created', (agent) => { + if (agent.id !== agentId) return + dispose() + createStdioChat(ctx, config, runtime) + }) +} + /** * Cordis entry point. Binds the real `process` streams and delegates to - * {@link createStdioChat}; the indirection keeps the side-effecting handles out + * {@link mountStdio}; the indirection keeps the side-effecting handles out * of the testable core, which is why the unit suite drives `createStdioChat` * directly. This thin wrapper is exercised end-to-end by the keyless * Loader-path e2e smoke in `examples/echo-agent` (the real product entry). */ /* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */ export function apply(ctx: Context, config: Config): void { - createStdioChat(ctx, config, { + mountStdio(ctx, config, { input: process.stdin, output: process.stdout, exit: code => process.exit(code), diff --git a/packages/ui/stdio/tests/plugin-shape.spec.ts b/packages/ui/stdio/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..5b2b35f65e --- /dev/null +++ b/packages/ui/stdio/tests/plugin-shape.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as stdio from '../src/index.ts' + +/** Real Loader export-path guard for the namespace stdio plugin. */ +describe('dsh-stdio plugin export shape', () => { + it('preserves name, inject, Config, and apply through Loader unwrapping', () => { + expect('default' in stdio).toBe(false) + expect(typeof stdio.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(stdio) as Record + expect(unwrapped).toBe(stdio) + expect(unwrapped.name).toBe('ui-stdio') + expect(unwrapped.inject).toEqual(['agents', 'userInteraction']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts similarity index 93% rename from packages/ui/stdio-agent/tests/readline.spec.ts rename to packages/ui/stdio/tests/readline.spec.ts index a958c435c1..638e98bf59 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio/tests/readline.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import type { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' -import type { StdioRuntime } from '../src/stdio-chat.ts' +import type { StdioRuntime } from '../src/index.ts' const createInterface = vi.hoisted(() => vi.fn(() => { const reader = new EventEmitter() as EventEmitter & { close(): void } @@ -33,7 +33,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { describe('createStdioChat readline mode', () => { it('enables terminal editing only when both stdio streams are TTYs', async () => { - const { createStdioChat } = await import('../src/stdio-chat.ts') + const { createStdioChat } = await import('../src/index.ts') const tty = fakeRuntime(true, true) createStdioChat(fakeContext(), {}, tty) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts similarity index 94% rename from packages/ui/stdio-agent/tests/stdio-chat.spec.ts rename to packages/ui/stdio/tests/stdio.spec.ts index f734acfd0d..7bb6a6f245 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' +import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' /** * Unit tests for the stdio UI plugin. They drive the REAL plugin body @@ -93,6 +93,55 @@ function flushExit(): Promise { return new Promise(resolve => setTimeout(resolve, 250)) } +describe('mountStdio readiness', () => { + it('leaves stdin untouched until the configured agent is created', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const { runtime, out } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + expect(out.text()).toBe('') + ctx.agents.register(makeAgent('other')) + expect(out.text()).toBe('') + ctx.agents.register(makeAgent('main')) + expect(out.text()).toBe('hi there\n> ') + await fiber.dispose() + }) + + it('opens immediately when the configured agent already exists', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.agents.register(makeAgent('main')) + const { runtime, out } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + expect(out.text()).toBe('hi there\n> ') + await fiber.dispose() + }) + + it('waits for main when no target agent is configured', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const { runtime, out } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, { welcome: 'ready' }, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + ctx.agents.register(makeAgent('other')) + expect(out.text()).toBe('') + ctx.agents.register(makeAgent('main')) + expect(out.text()).toBe('ready\n> ') + await fiber.dispose() + }) +}) + describe('createStdioChat rendering', () => { it('writes the welcome banner and prompt on start', async () => { const { out } = await setup() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json new file mode 100644 index 0000000000..00cb815a75 --- /dev/null +++ b/packages/ui/stdio/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../user-interaction" + } + ] +} diff --git a/packages/ui/tool-ask-user/package.json b/packages/ui/tool-ask-user/package.json index c1860f48f0..5ee90f0818 100644 --- a/packages/ui/tool-ask-user/package.json +++ b/packages/ui/tool-ask-user/package.json @@ -25,7 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -33,6 +33,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/user-approval/package.json b/packages/ui/user-approval/package.json index 602fee53af..1696a7b603 100644 --- a/packages/ui/user-approval/package.json +++ b/packages/ui/user-approval/package.json @@ -28,7 +28,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -40,6 +40,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/user-interaction/package.json b/packages/ui/user-interaction/package.json index f333195ac7..f4c9c411fd 100644 --- a/packages/ui/user-interaction/package.json +++ b/packages/ui/user-interaction/package.json @@ -24,11 +24,11 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 8059952170..7074aaa621 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -22,9 +22,9 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 150a155324..381b9d269e 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -22,9 +22,9 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 2abba46201..4935751082 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -32,7 +32,7 @@ Each tool is registered independently; a product that wants only one disables th Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config. -The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner. +The tool never calls a provider's `available()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner. ## Model Experience diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 80fd69dbc3..f6b5791f0f 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -26,7 +26,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 78dde7b79e..a6b8883f4d 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -96,7 +96,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void { const input = parseFetchArgs(args) const result = await ctx.web.fetch( { url: input.url }, - exec.signal ? { signal: exec.signal } : undefined, + exec.signal, ) return [{ type: 'text', text: formatFetchOutput(result) }] }, diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index a7587d328b..6db829b7fc 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -113,7 +113,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: const input = parseSearchArgs(args) const result = await ctx.web.search( { query: input.query, maxResults }, - exec.signal ? { signal: exec.signal } : undefined, + exec.signal, ) return [{ type: 'text', text: formatSearchOutput(result) }] }, diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 280c96b5bd..e99f524b3c 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -134,7 +134,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc await tctx.plugin(ToolRegistry) await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) // Provider backstop well ABOVE the tool-call budget, so the policy wins. - await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 }) + await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000 }) await tctx.plugin(TimeoutPolicy) // The tool-call budget is declared by tool-web config, enforced by the policy. tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 }) @@ -156,11 +156,18 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc expect(text).toContain('timed out after 50ms') }) - it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => { - // A direct seam caller does not go through tools/execute, so the tool-call policy never - // applies; the provider's own timeout is the only budget. A short request hint must therefore - // produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`. - const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( + it('the provider backstop still protects a direct provider call (no tool-call policy in that path)', async () => { + // A direct provider caller bypasses tools/execute, so a short configured backstop + // must produce provider-owned WEB_FETCH_TIMEOUT rather than TOOL_TIMEOUT. + const direct = new WebFetchLocal.LocalFetchProvider({ + maxUrlLength: 2048, + maxResponseBytes: 5_000_000, + maxBodyChars: 100_000, + timeoutMs: 50, + maxRedirects: 5, + userAgent: 'integration-test', + }) + const err = await direct.fetch({ url: slowBase }).then( () => undefined, (e: unknown) => e as { code?: string }, ) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 4bb2728df7..a9f66d3746 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import WebService from '@deepseek-ai/dsh-web' -import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' +import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import { formatSearchOutput, @@ -18,10 +18,10 @@ import { WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' -const available: WebProviderStatus = { available: true } +const available = true -function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider { - return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) } +function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider { + return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) } } /** Mount the real registry, seam, and tool-web; return an executor helper. */ @@ -46,7 +46,7 @@ async function mountTools(opts: { describe('search formatting', () => { it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => { const out = formatSearchOutput({ - providerId: 'p', query: 'q', content: 'an answer', truncated: false, + content: 'an answer', truncated: false, sources: [ { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, { url: 'https://b.test/y' }, @@ -59,19 +59,19 @@ describe('search formatting', () => { }) it('reports no results when there is neither content nor sources', () => { - expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false })) + expect(formatSearchOutput({ sources: [], truncated: false })) .toContain('No results found.') }) it('renders content alone when there are no sources', () => { - const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false }) + const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false }) expect(out).toContain('just an answer') expect(out).not.toContain('No results found.') expect(out).not.toContain('Sources:') }) it('notes truncation', () => { - const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true }) + const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true }) expect(out).toContain('Showing the first 1 sources') }) @@ -88,7 +88,7 @@ describe('search formatting', () => { describe('fetch formatting', () => { it('renders an html body to markdown text with a status header', () => { const out = formatFetchOutput({ - providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false, + url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'html', content: '

Title

Body text

' }, }) expect(out).toContain('Fetched https://a.test (HTTP 200)') @@ -98,7 +98,7 @@ describe('fetch formatting', () => { it('passes a text body through and notes truncation', () => { const out = formatFetchOutput({ - providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true, + url: 'https://a.test', statusCode: 200, truncated: true, body: { kind: 'text', content: 'plain' }, }) expect(out).toContain('plain') @@ -155,7 +155,7 @@ describe('htmlToMarkdown', () => { }) it('falls back to the raw URL as a source label when the URL is unparseable', () => { - const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] }) + const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] }) expect(out).toContain('[not a url](not a url)') }) }) @@ -209,7 +209,7 @@ describe('tool-web registration', () => { describe('tool-web execution through the real registry', () => { it('executes web_search and formats the result', async () => { const result: WebSearchResult = { - providerId: 'stub-search', query: 'q', content: 'answer', truncated: false, + content: 'answer', truncated: false, sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }], } const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) @@ -228,8 +228,8 @@ describe('tool-web execution through the real registry', () => { }) it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => { - const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) - ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) }) + const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) }) + ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) }) const out = await call('web_search', { query: 'q' }) expect(out.isError).toBe(true) expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS') @@ -237,7 +237,7 @@ describe('tool-web execution through the real registry', () => { }) it('rejects invalid arguments with a structured INVALID_ARGS error', async () => { - const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) }) const out = await call('web_search', { query: 123 }) expect(out.isError).toBe(true) expect(out.error?.code).toBe('INVALID_ARGS') @@ -249,14 +249,14 @@ describe('tool-web execution through the real registry', () => { }) it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => { - const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {} + const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {} const fetchProvider = { id: 'stub-fetch', - status: () => available, - fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => { + available: () => available, + fetch: (request: { url: string }, signal?: AbortSignal) => { seen.request = request - seen.signal = exec?.signal - return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + seen.signal = signal + return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) }, } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) @@ -271,21 +271,21 @@ describe('tool-web execution through the real registry', () => { }) it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => { - const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {} + const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {} const fetchProvider = { id: 'stub-fetch', - status: () => available, - fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => { - seen.passedExec = exec !== undefined - seen.signal = exec?.signal - return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + available: () => available, + fetch: (request: { url: string }, signal?: AbortSignal) => { + seen.passedSignal = signal !== undefined + seen.signal = signal + return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) }, } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) - // No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`). + // No signal on the execution: the tool passes `undefined`. const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) expect(out.isError).toBe(false) - expect(seen.passedExec).toBe(false) + expect(seen.passedSignal).toBe(false) expect(seen.signal).toBeUndefined() await fiber.dispose() }) @@ -294,8 +294,8 @@ describe('tool-web execution through the real registry', () => { const seen: { signal?: AbortSignal | undefined } = {} const provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + available: () => available, + search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) }, } const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const controller = new AbortController() @@ -310,8 +310,8 @@ describe('searchMaxResults is plugin config', () => { const seen: { maxResults?: number | undefined } = {} const provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + available: () => available, + search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) }, } const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) await call('web_search', { query: 'q' }) @@ -323,8 +323,8 @@ describe('searchMaxResults is plugin config', () => { const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` })) const provider: WebSearchProvider = { id: 'stub-search', - status: () => available, - search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }), + available: () => available, + search: () => Promise.resolve({ sources, truncated: false }), } const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) const out = await call('web_search', { query: 'q' }) diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index 265efea4f8..018aa97c03 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -8,7 +8,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. -The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`. +The provider's `timeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`. A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed. @@ -28,8 +28,7 @@ A shipping web-tool deployment sets the provider backstop above the tool budget, | `maxUrlLength` | `2048` | Maximum accepted request URL length. | | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | -| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). | -| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). | +| `timeoutMs` | `30_000` | Fetch timeout within Node's timer range — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). | | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. | diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 9b847db6f3..1e1d7ea71b 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -24,7 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -32,6 +32,6 @@ "devDependencies": { "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index b6f97bf0d9..471c61af2d 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -13,6 +13,8 @@ import type {} from '@deepseek-ai/dsh-web' import { LocalFetchProvider } from './provider.ts' import type { LocalFetchLimits } from './provider.ts' +const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647 + export { LOCAL_FETCH_PROVIDER_ID, LocalFetchProvider, @@ -38,10 +40,8 @@ export interface Config { maxResponseBytes?: number /** Maximum decoded body length in characters. */ maxBodyChars?: number - /** Default fetch timeout in milliseconds. */ + /** Default fetch timeout in milliseconds, within Node's timer range. */ timeoutMs?: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs?: number /** Maximum number of same-origin redirect hops to follow. */ maxRedirects?: number /** `User-Agent` header sent on every request. */ @@ -53,7 +53,6 @@ export const Config: z = z.object({ maxResponseBytes: z.number().default(5_000_000), maxBodyChars: z.number().default(100_000), timeoutMs: z.number().default(30_000), - maxTimeoutMs: z.number().default(120_000), maxRedirects: z.number().default(5), userAgent: z.string().default(DEFAULT_USER_AGENT), }) @@ -68,6 +67,14 @@ function assertPositiveFinite(name: string, value: number): void { } } +/** Node coerces larger timer delays to 1 ms, so reject them at configuration time. */ +function assertTimeoutMs(value: number): void { + assertPositiveFinite('timeoutMs', value) + if (value > MAX_NODE_TIMER_DELAY_MS) { + throw new Error(`web-fetch-local: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`) + } +} + /** The redirect hop cap must be a non-negative integer (0 follows no redirects). */ function assertNonNegativeInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 0) { @@ -82,15 +89,13 @@ export function apply(ctx: Context, config: Config): void { assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) - assertPositiveFinite('timeoutMs', resolved.timeoutMs) - assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertTimeoutMs(resolved.timeoutMs) assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) const limits: LocalFetchLimits = { maxUrlLength: resolved.maxUrlLength, maxResponseBytes: resolved.maxResponseBytes, maxBodyChars: resolved.maxBodyChars, timeoutMs: resolved.timeoutMs, - maxTimeoutMs: resolved.maxTimeoutMs, maxRedirects: resolved.maxRedirects, userAgent: resolved.userAgent, } diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 69f0509d64..ff71d11c60 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -9,8 +9,8 @@ */ import { WebError } from '@deepseek-ai/dsh-web' -import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' -import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ @@ -23,8 +23,6 @@ export interface LocalFetchLimits { maxBodyChars: number /** Default fetch timeout in milliseconds. */ timeoutMs: number - /** Upper bound for a per-request timeout override. */ - maxTimeoutMs: number /** Maximum number of (same-origin) redirect hops to follow. */ maxRedirects: number /** `User-Agent` header sent on every request. */ @@ -41,17 +39,16 @@ export class LocalFetchProvider implements WebFetchProvider { constructor(private readonly limits: LocalFetchLimits) {} /** No credentials to check — an anonymous public fetcher is always usable. */ - status(): WebProviderStatus { - return { available: true } + available(): boolean { + return true } - async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise { - if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') - const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs) + async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') // One signal stops both the request and body read. The deadline's TimeoutReason later // distinguishes this provider's timeout from caller or outer-deadline cancellation. - using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT') + using d = deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT') return await this.followAndRead(request.url, d.signal) } @@ -142,7 +139,6 @@ export class LocalFetchProvider implements WebFetchProvider { const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } return { - providerId: this.id, url: finalUrl.toString(), statusCode: response.status, body, diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 7d4c4e683b..3158111134 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -12,7 +12,6 @@ const limits: LocalFetchLimits = { maxResponseBytes: 5_000_000, maxBodyChars: 100_000, timeoutMs: 5_000, - maxTimeoutMs: 10_000, maxRedirects: 5, userAgent: 'test-agent/1.0', } @@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => { it('fetches a text body', async () => { handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } const result = await provider().fetch({ url: base }) - expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID) + expect(provider().available()).toBe(true) expect(result.statusCode).toBe(200) expect(result.body).toEqual({ kind: 'text', content: 'hello world' }) expect(result.truncated).toBe(false) @@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => { it('honors a pre-aborted signal', async () => { const controller = new AbortController() controller.abort() - await expect(provider().fetch({ url: base }, { signal: controller.signal })) + await expect(provider().fetch({ url: base }, controller.signal)) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) it('aborts an in-flight fetch via the signal', async () => { handler = (_req, _res) => { /* never responds */ } const controller = new AbortController() - const promise = provider().fetch({ url: base }, { signal: controller.signal }) + const promise = provider().fetch({ url: base }, controller.signal) controller.abort() await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) @@ -325,11 +324,6 @@ describe('LocalFetchProvider invalid URLs and abort', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) - it('caps the per-request timeout at maxTimeoutMs', async () => { - handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') } - const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 }) - expect(result.statusCode).toBe(200) - }) }) describe('LocalFetchProvider body cancellation on error paths', () => { @@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => { await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, {}) await expect(ctx.web.fetch({ url: `${base}/` })) - .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + .resolves.toMatchObject({ statusCode: 200 }) await fiber.dispose() await expect(ctx.web.fetch({ url: `${base}/` })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) @@ -402,6 +396,13 @@ describe('web-fetch-local plugin registration', () => { .rejects.toThrow(/timeoutMs must be a positive finite number/) }) + it('rejects a timeout beyond Node timer range at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 })) + .rejects.toThrow(/timeoutMs must be no greater than 2147483647/) + }) + it('rejects a fractional redirect cap at construction', async () => { const ctx = new Context() await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) @@ -421,7 +422,7 @@ describe('web-fetch-local plugin registration', () => { await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) await expect(ctx.web.fetch({ url: `${base}/` })) - .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) + .resolves.toMatchObject({ statusCode: 200 }) await fiber.dispose() }) }) diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index eba4732d7d..19ca2e07c4 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | -| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. | +| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 617e9f2768..42471006c0 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -23,13 +23,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index c972a0f8c0..b272565441 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -8,7 +8,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -50,7 +49,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface DeepSeekSearchProviderOptions { - /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ + /** DeepSeek API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/messages` is appended. */ baseURL: string @@ -93,12 +92,11 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map block.type === 'web_search_tool_result', @@ -126,7 +124,7 @@ export function mapAnthropicResponse(query: string, response: AnthropicResponse) }) } } - return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false } + return { sources, truncated: false } } /** The DeepSeek-backed search provider. */ @@ -135,14 +133,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { constructor(private readonly options: DeepSeekSearchProviderOptions) {} - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && URL.canParse(this.options.baseURL) + && isPositiveInteger(this.options.maxTokens) + && isPositiveInteger(this.options.maxUses) } - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/messages`, { @@ -166,7 +164,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { }], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) @@ -194,7 +192,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { try { const payload = await response.json() as AnthropicResponse - return mapAnthropicResponse(request.query, payload) + return mapAnthropicResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) if (error instanceof WebError) throw error diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts index f828fbd12e..03c99f9d9b 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -29,7 +29,6 @@ maybe('DeepSeekSearchProvider real API', () => { maxUses: DEEPSEEK_DEFAULT_MAX_USES, }) const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) - expect(result.providerId).toBe('deepseek') expect(result.sources.length).toBeGreaterThan(0) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 60_000) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 863099f697..3d8d4be597 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -64,10 +64,8 @@ describe('citationSnippets', () => { describe('mapAnthropicResponse', () => { it('joins result items to citation snippets and maps page_age to publishedAt', () => { - const result = mapAnthropicResponse('q', searchResponse()) + const result = mapAnthropicResponse(searchResponse()) expect(result).toEqual({ - providerId: DEEPSEEK_PROVIDER_ID, - query: 'q', sources: [ { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' }, { url: 'https://b.test', title: 'B' }, @@ -77,7 +75,7 @@ describe('mapAnthropicResponse', () => { }) it('dedupes repeated urls across result blocks (first wins)', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [ { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] }, @@ -87,7 +85,7 @@ describe('mapAnthropicResponse', () => { }) it('skips non-result items and items with an empty url', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [{ type: 'web_search_tool_result', content: [ @@ -101,14 +99,14 @@ describe('mapAnthropicResponse', () => { }) it('omits optional fields when absent or empty', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }], }) expect(result.sources).toEqual([{ url: 'https://a.test' }]) }) it('tolerates a text block with no citations', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [ { type: 'text', text: 'no citations here' }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] }, @@ -118,7 +116,7 @@ describe('mapAnthropicResponse', () => { }) it('tolerates a result block with no content array', () => { - const result = mapAnthropicResponse('q', { + const result = mapAnthropicResponse({ content: [ { type: 'web_search_tool_result' }, { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] }, @@ -128,38 +126,33 @@ describe('mapAnthropicResponse', () => { }) it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => { - expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] })) + expect(() => mapAnthropicResponse({ content: [{ type: 'text', text: 'just prose, no search' }] })) .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => { - expect(() => mapAnthropicResponse('q', {})) + expect(() => mapAnthropicResponse({})) .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) }) -describe('DeepSeekSearchProvider status', () => { +describe('DeepSeekSearchProvider availability', () => { it('is unavailable without a key', () => { - expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) it('is available with a key', () => { - expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true }) + expect(new DeepSeekSearchProvider(options).available()).toBe(true) }) it('is misconfigured when the base URL is unparseable', () => { - expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when request limits are not positive integers', () => { - expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false) }) }) @@ -186,7 +179,7 @@ describe('DeepSeekSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal) const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(init.signal).toBe(controller.signal) }) @@ -268,7 +261,7 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false }) await fiber.dispose() await expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) @@ -318,7 +311,7 @@ describe('web-search-deepseek plugin registration', () => { const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters[0] // A collapsed export shape (dropped inject) would throw "without inject" here. const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false }) await fiber.dispose() }) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index ad53a94034..2cabf42732 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | -| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent makes the provider unavailable. | +| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable. | | `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. | | `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. | | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. | diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a111daa287..240909e24a 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -23,13 +23,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index e4eb620aba..ffc82683b1 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -8,7 +8,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -33,7 +32,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface ExaSearchProviderOptions { - /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */ + /** Exa API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/search` is appended. */ baseURL: string @@ -68,18 +67,17 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined { /** * Map an Exa response envelope to a normalized search result. * - * @param query - the original request query, echoed on the result. * @param response - the parsed `POST /search` response body. * @returns the normalized result; snippet-less entries are dropped * ({@link mapExaResult}). */ -export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { +export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { const sources = (response.results ?? []) .map(mapExaResult) .filter((source): source is WebSearchSource => source !== undefined) // Exa returns no generated answer, so `content` is omitted. The seam owns the // final `maxResults` truncation, so this provider reports `truncated: false`. - return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false } + return { sources, truncated: false } } /** The Exa-backed search provider. */ @@ -88,15 +86,14 @@ export class ExaSearchProvider implements WebSearchProvider { constructor(private readonly options: ExaSearchProviderOptions) {} - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' } - if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && isValidBaseUrl(this.options.baseURL) + && isPositiveInteger(this.options.highlightsPerResult) + && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults)) } - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { // A per-request bound wins over the configured default; either may be absent. const numResults = request.maxResults ?? this.options.numResults let response: Response @@ -115,7 +112,7 @@ export class ExaSearchProvider implements WebSearchProvider { contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, ...numResults !== undefined ? { numResults } : {}, }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) @@ -143,7 +140,7 @@ export class ExaSearchProvider implements WebSearchProvider { try { const payload = await response.json() as ExaSearchResponse - return mapExaResponse(request.query, payload) + return mapExaResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts index c9e9233bc0..ff04371881 100644 --- a/packages/web/web-search-exa/tests/exa.e2e.ts +++ b/packages/web/web-search-exa/tests/exa.e2e.ts @@ -17,7 +17,6 @@ maybe('ExaSearchProvider real API', () => { highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, }) const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 }) - expect(result.providerId).toBe('exa') expect(result.sources.length).toBeGreaterThan(0) for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 30_000) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 204b18f702..cf66ea67f9 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -38,7 +38,7 @@ describe('Exa result mapping', () => { }) it('maps a response to a result with no content and filtered sources', () => { - const result = mapExaResponse('q', { + const result = mapExaResponse({ results: [ { url: 'https://a.test', highlights: ['one'] }, { url: 'https://b.test' }, @@ -46,8 +46,6 @@ describe('Exa result mapping', () => { ], }) expect(result).toEqual({ - providerId: EXA_PROVIDER_ID, - query: 'q', sources: [ { url: 'https://a.test', snippet: 'one' }, { url: 'https://c.test', title: 'C', snippet: 'three' }, @@ -58,36 +56,31 @@ describe('Exa result mapping', () => { }) it('tolerates a missing results array', () => { - expect(mapExaResponse('q', {}).sources).toEqual([]) + expect(mapExaResponse({}).sources).toEqual([]) }) }) -describe('ExaSearchProvider status', () => { +describe('ExaSearchProvider availability', () => { it('is unavailable without a key', () => { - expect(new ExaSearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) it('is available with a key', () => { - expect(new ExaSearchProvider(options).status()).toEqual({ available: true }) + expect(new ExaSearchProvider(options).available()).toBe(true) }) it('is misconfigured when the base URL is unparseable', () => { - expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when highlightsPerResult is not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false) }) it('is misconfigured when numResults is set but not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, numResults: -1 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false) }) }) @@ -139,7 +132,7 @@ describe('ExaSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal) const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(init.signal).toBe(controller.signal) }) @@ -209,7 +202,7 @@ describe('web-search-exa plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false }) await fiber.dispose() await expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index 84415c03b4..4f4cf4896f 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | -| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent makes the provider unavailable. | +| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable. | | `model` | `sonar` | Search model name. | | `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. | | `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. | diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index fde44ddd16..26d077fada 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -23,13 +23,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index f0502786b4..8ec5231627 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -8,7 +8,6 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -36,7 +35,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1' /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ export interface PerplexitySearchProviderOptions { - /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ + /** Perplexity API key. Empty/absent makes the provider unavailable. */ apiKey: string /** Endpoint base; `/chat/completions` is appended. */ baseURL: string @@ -68,18 +67,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo * structured `search_results[]`; falls back to URL-only `citations[]` (those * sources carry just a `url`) only when `search_results` is absent. * - * @param query - the original request query, echoed on the result. * @param response - the parsed chat-completions response body. * @returns the normalized result; `content` is omitted when the answer is empty. */ -export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult { +export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult { const content = response.choices?.[0]?.message?.content const sources: WebSearchSource[] = response.search_results !== undefined ? response.search_results.map(mapPerplexityResult) : (response.citations ?? []).map(url => ({ url })) return { - providerId: PERPLEXITY_PROVIDER_ID, - query, ...content != null && content.length > 0 ? { content } : {}, sources, truncated: false, @@ -95,15 +91,14 @@ export class PerplexitySearchProvider implements WebSearchProvider { // Availability checks stay beside each provider's distinct config contract; // a shared base class would obscure which fields make this backend usable. /* jscpd:ignore-start */ - status(): WebProviderStatus { - if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } - if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } - if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } - return { available: true } + available(): boolean { + return this.options.apiKey.length > 0 + && URL.canParse(this.options.baseURL) + && isPositiveInteger(this.options.maxTokens) } /* jscpd:ignore-end */ - async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { let response: Response try { response = await fetch(`${this.options.baseURL}/chat/completions`, { @@ -120,7 +115,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { messages: [{ role: 'user', content: request.query }], ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, }), - ...exec?.signal ? { signal: exec.signal } : {}, + ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) @@ -148,7 +143,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { try { const payload = await response.json() as PerplexityResponse - return mapPerplexityResponse(request.query, payload) + return mapPerplexityResponse(payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts index 2fc89db7d4..e8f7474caa 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts @@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => { maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, }) const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 }) - expect(result.providerId).toBe('perplexity') expect(result.content ?? '').not.toBe('') for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) }, 30_000) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index df8a98f003..e769989d73 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -20,7 +20,7 @@ afterEach(() => { describe('Perplexity response mapping', () => { it('maps the answer and prefers structured search_results', () => { - const result = mapPerplexityResponse('q', { + const result = mapPerplexityResponse({ choices: [{ message: { content: 'the answer' } }], search_results: [ { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, @@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => { citations: ['https://ignored.test'], }) expect(result).toEqual({ - providerId: PERPLEXITY_PROVIDER_ID, - query: 'q', content: 'the answer', sources: [ { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' }, @@ -41,7 +39,7 @@ describe('Perplexity response mapping', () => { }) it('falls back to URL-only citations when search_results is absent', () => { - const result = mapPerplexityResponse('q', { + const result = mapPerplexityResponse({ choices: [{ message: { content: 'answer' } }], citations: ['https://a.test', 'https://b.test'], }) @@ -49,43 +47,39 @@ describe('Perplexity response mapping', () => { }) it('omits content when the answer is empty or missing', () => { - expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined() - expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined() - expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined() + expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined() + expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined() + expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined() }) it('omits null/empty optional source fields', () => { - const result = mapPerplexityResponse('q', { + const result = mapPerplexityResponse({ search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }], }) expect(result.sources).toEqual([{ url: 'https://a.test' }]) }) it('yields no sources when neither search_results nor citations are present', () => { - expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) + expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) }) }) -describe('PerplexitySearchProvider status', () => { +describe('PerplexitySearchProvider availability', () => { it('is unavailable without a key', () => { - expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status()) - .toEqual({ available: false, reason: 'missing-credential' }) + expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) it('is available with a key', () => { - expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true }) + expect(new PerplexitySearchProvider(options).available()).toBe(true) }) it('is misconfigured when the base URL is unparseable', () => { - expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when maxTokens is not a positive integer', () => { - expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) - expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status()) - .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false) }) }) @@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal) const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(init.signal).toBe(controller.signal) }) @@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] }) await fiber.dispose() await expect(ctx.web.search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 01677ed344..0fcfad7e1e 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del | Member | Semantics | |---|---| | `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. | -| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | -| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | +| `search(request, signal?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | +| `fetch(request, signal?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. @@ -30,18 +30,18 @@ Selection never depends on registration, config, or HMR order. A capability has | Situation | Execution | |---|---| -| configured id registered and `status().available` | runs that provider | +| configured id registered and `available()` | runs that provider | | configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` | | configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | no id, exactly one registered usable provider | runs it | | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | | no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | -The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner. +The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `available()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls it — the tool executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner. ## Vocabulary -`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy. +`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`) → `WebFetchResult` (final `url`, `statusCode`, `body`, `truncated`); cancellation is a direct optional `AbortSignal` argument to `search()`/`fetch()`. `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy. ## Model Experience diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 8c68c58203..b94f7ba685 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -23,13 +23,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 1775b0a04a..38e1478c3c 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -9,11 +9,9 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { - WebExecContext, WebFetchProvider, WebFetchRequest, WebFetchResult, - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -24,12 +22,10 @@ export { WebError, } from './types.ts' export type { - WebExecContext, WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, - WebProviderStatus, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -67,7 +63,7 @@ export interface WebServiceConfig { * The web access service. Registered as `ctx.web` (one instance per context). * * Selection semantics (resolved at execution time, never order-dependent): - * - A configured id that is registered and `status().available` → that provider. + * - A configured id that is registered and `available()` → that provider. * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. * - A configured id registered but unavailable → * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. @@ -138,15 +134,15 @@ export class WebService extends Service { * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. * @param request - the query plus result-shaping options. - * @param exec - the tool-execution context, forwarded to the provider. + * @param signal - optional cancellation signal forwarded to the provider. * @returns the provider's results, capped to `request.maxResults`. */ - async search(request: WebSearchRequest, exec?: WebExecContext): Promise { + async search(request: WebSearchRequest, signal?: AbortSignal): Promise { const provider = resolveProvider({ providers: this.searchProviders, ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, }) - const result = await provider.search(request, exec) + const result = await provider.search(request, signal) return capSources(result, request.maxResults) } @@ -155,21 +151,21 @@ export class WebService extends Service { * call time with the selection rules above; throws {@link WebError} when the * capability cannot run. A non-2xx response is a result, not a throw. * @param request - the URL plus retrieval options. - * @param exec - the tool-execution context, forwarded to the provider. + * @param signal - optional cancellation signal forwarded to the provider. * @returns the retrieval outcome; non-2xx responses resolve descriptively. */ - async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise { + async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise { const provider = resolveProvider({ providers: this.fetchProviders, ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, }) - return provider.fetch(request, exec) + return provider.fetch(request, signal) } } interface ResolvableProvider { readonly id: string - status(): WebProviderStatus + available(): boolean } /** Resolve the selected provider or throw the matching {@link WebError}. */ @@ -180,12 +176,12 @@ function resolveProvider

(selection: Selection

): if (!provider) { throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') } - if (!provider.status().available) { + if (!provider.available()) { throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE') } return provider } - const usable = [...providers.values()].filter(provider => provider.status().available) + const usable = [...providers.values()].filter(provider => provider.available()) const [single] = usable if (single === undefined) { throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index b43fb010d5..1216d09848 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -7,19 +7,6 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' -/** - * Execution control threaded from the tool layer through the seam into a - * provider's network requests, stream readers, and expensive decoding. It is - * NOT business input: the first version carries only `signal` so `tool-web` can - * propagate turn cancellation, tool timeout, and agent disposal. It deliberately - * does NOT carry `ToolExecution`, which would make `dsh-web` depend on - * `dsh-tools`. - */ -export interface WebExecContext { - /** Abort signal a provider must honor for its network/decoding work. */ - readonly signal?: AbortSignal -} - /** * What one search-capable backend can return. The model-facing argument is just * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged @@ -44,10 +31,6 @@ export interface WebSearchRequest { * when it cut `sources[]` down to `maxResults`. */ export interface WebSearchResult { - /** Id of the provider that produced this result. */ - readonly providerId: string - /** Echo of the query the provider answered. */ - readonly query: string /** Optional provider-generated answer text, search context, or summary. */ readonly content?: string /** Citeable sources, already truncated to the request's `maxResults`. */ @@ -71,14 +54,13 @@ export interface WebSearchSource { } /** - * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an - * optional positive hint the provider caps. The request deliberately omits - * `format`, `prompt`, and extraction controls — those are presentation or - * higher-level LLM concerns, not safe-retrieval inputs. + * What one fetch-capable backend is asked to retrieve. The request deliberately + * omits timeout, format, prompt, and extraction controls: cancellation is a + * direct execution argument, while presentation and higher-level LLM concerns + * belong outside safe retrieval. */ export interface WebFetchRequest { readonly url: string - readonly timeoutMs?: number } /** @@ -88,8 +70,6 @@ export interface WebFetchRequest { * represent the resource. */ export interface WebFetchResult { - /** Id of the provider that produced this result. */ - readonly providerId: string /** The final URL after allowed redirects (the request URL is in the request). */ readonly url: string /** HTTP status code of the fetched response. */ @@ -113,18 +93,6 @@ export type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } -/** - * Whether one concrete provider implementation is usable, by cheap local checks - * only (credential presence, parseable endpoint config). A provider `status()` - * must NOT make network calls. It is an input to execution-time selection, not - * a health system: `WebService.search()`/`fetch()` read it to pick a usable - * provider, and selection failure surfaces as the structured {@link WebError} - * codes callers route on. - */ -export type WebProviderStatus = - | { readonly available: true } - | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } - /** * A search-capable backend. Registered with `ctx.web.registerSearchProvider`. * `id` is a stable string, unique within the search capability kind. @@ -132,9 +100,9 @@ export type WebProviderStatus = export interface WebSearchProvider { readonly id: string /** Cheap local usability check; must not make network calls. */ - status(): WebProviderStatus - /** Run one search; honor `exec.signal` for cancellation. */ - search(request: WebSearchRequest, exec?: WebExecContext): Promise + available(): boolean + /** Run one search; honor `signal` for cancellation. */ + search(request: WebSearchRequest, signal?: AbortSignal): Promise } /** @@ -144,9 +112,9 @@ export interface WebSearchProvider { export interface WebFetchProvider { readonly id: string /** Cheap local usability check; must not make network calls. */ - status(): WebProviderStatus - /** Retrieve one URL; honor `exec.signal` for cancellation. */ - fetch(request: WebFetchRequest, exec?: WebExecContext): Promise + available(): boolean + /** Retrieve one URL; honor `signal` for cancellation. */ + fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } /** diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index 8189e342da..978284ee51 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -4,7 +4,6 @@ import WebService, { WebError, type WebFetchProvider, type WebFetchResult, - type WebProviderStatus, type WebSearchProvider, type WebSearchRequest, type WebSearchResult, @@ -13,25 +12,25 @@ import WebService, { /** A scripted search provider for contract tests. */ function makeSearchProvider( id: string, - status: WebProviderStatus, + available: boolean, search: (request: WebSearchRequest) => Promise, ): WebSearchProvider { - return { id, status: () => status, search: request => search(request) } + return { id, available: () => available, search: request => search(request) } } -function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider { - return { id, status: () => status, fetch: () => Promise.resolve(result) } +function makeFetchProvider(id: string, available: boolean, result: WebFetchResult): WebFetchProvider { + return { id, available: () => available, fetch: () => Promise.resolve(result) } } -const available: WebProviderStatus = { available: true } -const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' } +const available = true +const unavailable = false -function searchResult(providerId: string, overrides: Partial = {}): WebSearchResult { - return { providerId, query: 'q', sources: [], truncated: false, ...overrides } +function searchResult(marker: string, overrides: Partial = {}): WebSearchResult { + return { content: marker, sources: [], truncated: false, ...overrides } } -function fetchResult(providerId: string): WebFetchResult { - return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false } +function fetchResult(marker: string): WebFetchResult { + return { url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: marker }, truncated: false } } /** Mount a WebService on a fresh root context with the given config. */ @@ -46,7 +45,7 @@ describe('WebService registration', () => { const { web } = await mountWeb() const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' }) dispose() await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) @@ -70,7 +69,7 @@ describe('WebService registration', () => { const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) }, { inject: ['web'] })) - await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' }) await fiber.dispose() await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) }) @@ -111,26 +110,26 @@ describe('WebService execution resolution', () => { const { web } = await mountWeb({ searchProvider: 'perplexity' }) web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' }) }) it('ignores unusable providers when auto-selecting', async () => { const { web } = await mountWeb() web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) - await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' }) }) it('does not let registration order change auto-selection', async () => { const a = await mountWeb() a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' }) const b = await mountWeb() b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) - await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' }) }) it('runs the selected provider and returns its result', async () => { @@ -139,7 +138,6 @@ describe('WebService execution resolution', () => { searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }), ))) const result = await web.search({ query: 'q' }) - expect(result.providerId).toBe('exa') expect(result.content).toBe('answer') expect(result.sources).toEqual([{ url: 'https://a' }]) }) @@ -149,11 +147,11 @@ describe('WebService execution resolution', () => { const seen: (AbortSignal | undefined)[] = [] web.registerSearchProvider({ id: 'exa', - status: () => available, - search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) }, + available: () => available, + search: (_request, signal) => { seen.push(signal); return Promise.resolve(searchResult('exa')) }, }) const controller = new AbortController() - await web.search({ query: 'q' }, { signal: controller.signal }) + await web.search({ query: 'q' }, controller.signal) expect(seen[0]).toBe(controller.signal) }) }) @@ -195,7 +193,7 @@ describe('WebService fetch capability', () => { const { web } = await mountWeb() web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http'))) const result = await web.fetch({ url: 'https://example.com' }) - expect(result.providerId).toBe('local-http') + expect(result.body.content).toBe('local-http') expect(result.statusCode).toBe(200) }) diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index f5c2138d85..327e6ec3a9 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -27,7 +27,7 @@ "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -41,6 +41,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index 677ca121e6..f27042114b 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -2,6 +2,8 @@ This package implements `WorkflowService` with one Node worker thread per run. The worker executes the orchestration script; child agents remain on the host and are reached through `ctx.subagents` over a typed host/worker protocol. +The package root exports the default engine plugin and its `Config`; the worker protocol, runtime, and session modules stay private to the implementation. The operational `./worker` entry remains the engine's spawn target. + The split has one primary purpose: a synchronous script loop cannot block the harness event loop, and a script that ignores cancellation can be terminated with its worker. It is not a security sandbox. ## Trust and isolation boundary diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index afaf840f78..485e252c97 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -34,7 +34,7 @@ "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "dependencies": { "schemastery": "^3.18.0" @@ -51,7 +51,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "cordis": "^4.0.0-rc.6", + "cordis": "^4.0.0-rc.7", "tsx": "^4.19.2" } } diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 44ab91f465..9fed7d3450 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -18,11 +18,7 @@ import { validateMeta } from './meta.ts' import type { WorkerInit, WorkerLimits } from './types.ts' export { validateMeta } from './meta.ts' -export { HostToWorkerType, WorkerToHostType } from './protocol.ts' -export type { HostToWorkerMessage, HostToWorkerPayloads, WorkerToHostMessage, WorkerToHostPayloads } from './protocol.ts' export { materializeFromRealm, MaterializeError } from './realm.ts' -export { WorkflowExecution, type ExecutionObserver } from './runtime.ts' -export { requireParentPort, runWorkerSession } from './session.ts' export type { ChildHandle, ChildPort, @@ -83,7 +79,7 @@ function assertBodyParses(body: string, name: string): void { * `result` never rejects; the `workflow/*` events fire around the run per * the seam contract. */ -export class WorkerWorkflowEngine extends WorkflowService { +class WorkerWorkflowEngine extends WorkflowService { static inject = ['subagents'] static Config: z = z.object({ diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index e85bec9375..0e12f6e4d9 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -9,7 +9,8 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' -import WorkerWorkflowEngine, { HostToWorkerType, WorkerToHostType, type Config } from '../src/index.ts' +import WorkerWorkflowEngine, { type Config } from '../src/index.ts' +import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { @@ -1326,6 +1327,7 @@ describe('dsh-workflow-workerthread', () => { it('has the class-plugin export shape (default = the engine service class)', () => { expect(workerEngineModule.default).toBe(WorkerWorkflowEngine) + expect('WorkerWorkflowEngine' in workerEngineModule).toBe(false) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports(workerEngineModule) expect(unwrapped).toBe(WorkerWorkflowEngine) diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index a6c004d6d0..696955db4f 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -25,13 +25,13 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ef91da5d8..3e9b3ea285 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.5.0(jiti@2.7.0)) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/jsdom': specifier: ^28.0.3 version: 28.0.3 @@ -35,6 +38,9 @@ importers: fast-check: specifier: ^4.8.0 version: 4.8.0 + js-yaml: + specifier: ^4.2.0 + version: 4.2.0 jscpd: specifier: ^5.0.12 version: 5.0.12 @@ -93,8 +99,8 @@ importers: specifier: workspace:^ version: link:../../core/session cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/bash/bash-local: dependencies: @@ -109,8 +115,8 @@ importers: specifier: workspace:^ version: link:../../util/timeout cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/bash/bash-sandbox: dependencies: @@ -131,8 +137,8 @@ importers: specifier: workspace:^ version: link:../../sandbox/sandbox-local cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) node-addon-landlock-run: specifier: 0.0.0-test.0 version: 0.0.0-test.0 @@ -176,14 +182,14 @@ importers: specifier: workspace:^ version: link:../../ui/user-approval cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/code-runtime/code-runtime: devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/code-runtime/code-runtime-worker: dependencies: @@ -195,8 +201,8 @@ importers: specifier: workspace:^ version: link:../code-runtime cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/compact/compact: devDependencies: @@ -207,8 +213,8 @@ importers: specifier: workspace:^ version: link:../../core/session cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/compact/compact-basic: devDependencies: @@ -237,8 +243,36 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/context/time-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/cordis/tool-cordis: dependencies: @@ -247,8 +281,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@cordisjs/plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer @@ -274,8 +308,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/agent: devDependencies: @@ -295,8 +329,8 @@ importers: specifier: workspace:^ version: link:../system-prompt cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/agent-core: dependencies: @@ -341,8 +375,8 @@ importers: specifier: workspace:^ version: link:../tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/agent-loop: dependencies: @@ -378,14 +412,14 @@ importers: specifier: workspace:^ version: link:../tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/scope: devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/session: devDependencies: @@ -399,8 +433,8 @@ importers: specifier: workspace:^ version: link:../scope cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/system-prompt: dependencies: @@ -415,8 +449,8 @@ importers: specifier: workspace:^ version: link:../scope cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/core/tools: dependencies: @@ -446,8 +480,8 @@ importers: specifier: workspace:^ version: link:../../ui/user-approval cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/fs/fs: devDependencies: @@ -458,8 +492,8 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/fs/fs-local: dependencies: @@ -474,8 +508,8 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/fs/fs-policy: devDependencies: @@ -486,8 +520,8 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/fs/tool-fs: dependencies: @@ -529,8 +563,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/guard/repeat-tool-guard: dependencies: @@ -557,8 +591,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hook-protocol: devDependencies: @@ -569,8 +603,8 @@ importers: specifier: workspace:^ version: link:../../core/session cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hooks-claude: dependencies: @@ -609,8 +643,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/hooks/hooks-codex: dependencies: @@ -646,8 +680,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm: devDependencies: @@ -655,8 +689,8 @@ importers: specifier: workspace:^ version: link:../../util/brand cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm-deepseek: dependencies: @@ -668,8 +702,8 @@ importers: specifier: workspace:^ version: link:../llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/llm/llm-pi-ai: dependencies: @@ -687,8 +721,8 @@ importers: specifier: workspace:^ version: link:../llm-deepseek cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/sandbox/sandbox: devDependencies: @@ -696,8 +730,8 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/sandbox/sandbox-local: dependencies: @@ -715,8 +749,8 @@ importers: specifier: workspace:^ version: link:../sandbox cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/session-persistence/session-persistence: devDependencies: @@ -724,8 +758,8 @@ importers: specifier: workspace:^ version: link:../../core/session cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/session-persistence/session-persistence-jsonl: dependencies: @@ -740,8 +774,8 @@ importers: specifier: workspace:^ version: link:../session-persistence cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/session-persistence/session-persistence-sqlite: dependencies: @@ -756,8 +790,8 @@ importers: specifier: workspace:^ version: link:../session-persistence cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/session-query/session-query: dependencies: @@ -775,8 +809,8 @@ importers: specifier: workspace:^ version: link:../../session-persistence/session-persistence cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/skill/skill: dependencies: @@ -785,8 +819,8 @@ importers: version: 3.18.0 devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/skill/skill-local: dependencies: @@ -804,8 +838,8 @@ importers: specifier: workspace:^ version: link:../skill cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/skill/tool-skill: dependencies: @@ -832,8 +866,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent: devDependencies: @@ -850,8 +884,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent-acp: dependencies: @@ -863,8 +897,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -878,8 +912,8 @@ importers: specifier: workspace:^ version: link:../subagent-subprocess cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent-fork: dependencies: @@ -888,8 +922,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -921,8 +955,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent-inprocess: devDependencies: @@ -951,8 +985,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent-spawn: dependencies: @@ -961,8 +995,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1003,14 +1037,14 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/subagent-subprocess: devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/subagent/tool-subagent: dependencies: @@ -1019,8 +1053,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1040,8 +1074,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/acp-snapshot: dependencies: @@ -1056,8 +1090,8 @@ importers: version: 4.1.8(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/invariants: devDependencies: @@ -1073,9 +1107,21 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/llm-replay: devDependencies: @@ -1085,9 +1131,19 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/support/loader-smoke: + dependencies: + tsx: + specifier: ^4.22.4 + version: 4.22.4 + devDependencies: cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/support/subagent-mock: dependencies: @@ -1096,8 +1152,8 @@ importers: version: 3.18.0 devDependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1108,8 +1164,8 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/timeout/timeout-policy: devDependencies: @@ -1123,8 +1179,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/todo/tool-todo: devDependencies: @@ -1147,8 +1203,8 @@ importers: specifier: workspace:^ version: link:../../core/tools cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/acp: dependencies: @@ -1226,8 +1282,8 @@ importers: specifier: workspace:^ version: link:../user-interaction cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/acp-agent: devDependencies: @@ -1262,8 +1318,8 @@ importers: specifier: workspace:^ version: link:../user-interaction cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) schemastery: specifier: ^3.17.0 version: 3.18.0 @@ -1277,8 +1333,8 @@ importers: specifier: workspace:^ version: link:../../../vendor/loader cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) packages/ui/jsonrpc: dependencies: @@ -1311,8 +1367,8 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/ui/jsonrpc-agent: dependencies: @@ -1321,8 +1377,8 @@ importers: version: link:../app-boot devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/permission: dependencies: @@ -1342,9 +1398,34 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/ui/stdio: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction cordis: specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/ui/stdio-agent: devDependencies: @@ -1375,6 +1456,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-stdio': + specifier: workspace:^ + version: link:../stdio '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1388,8 +1472,8 @@ importers: specifier: workspace:^ version: link:../user-interaction cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) schemastery: specifier: ^3.17.0 version: 3.18.0 @@ -1412,8 +1496,8 @@ importers: specifier: workspace:^ version: link:../user-interaction cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/user-approval: dependencies: @@ -1440,8 +1524,8 @@ importers: specifier: workspace:^ version: link:../../core/system-prompt cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/ui/user-interaction: devDependencies: @@ -1452,20 +1536,20 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/brand: devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/util/timeout: devDependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/tool-web: dependencies: @@ -1501,8 +1585,8 @@ importers: specifier: workspace:^ version: link:../web-search-exa cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/web: dependencies: @@ -1514,8 +1598,8 @@ importers: specifier: workspace:^ version: link:../../llm/llm cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/web-fetch-local: dependencies: @@ -1530,8 +1614,8 @@ importers: specifier: workspace:^ version: link:../web cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/web-search-deepseek: dependencies: @@ -1543,8 +1627,8 @@ importers: specifier: workspace:^ version: link:../web cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/web-search-exa: dependencies: @@ -1556,8 +1640,8 @@ importers: specifier: workspace:^ version: link:../web cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/web/web-search-perplexity: dependencies: @@ -1569,8 +1653,8 @@ importers: specifier: workspace:^ version: link:../web cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/workflow/tool-workflow: dependencies: @@ -1603,8 +1687,8 @@ importers: specifier: workspace:^ version: link:../workflow-workerthread cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/workflow/workflow: devDependencies: @@ -1621,8 +1705,8 @@ importers: specifier: workspace:^ version: link:../../core/session cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/workflow/workflow-workerthread: dependencies: @@ -1664,8 +1748,8 @@ importers: specifier: workspace:^ version: link:../workflow cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) tsx: specifier: ^4.19.2 version: 4.22.4 @@ -1875,10 +1959,10 @@ importers: dependencies: '@cordisjs/plugin-include': specifier: ^1.0.4 - version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) + version: 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -1891,11 +1975,11 @@ importers: vendor/group: dependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) vendor/hmr: dependencies: @@ -1904,13 +1988,13 @@ importers: version: 7.29.7 '@cordisjs/plugin-timer': specifier: ^1.1.2 - version: 1.1.2(cordis@4.0.0-rc.6) + version: 1.1.2(cordis@4.0.0-rc.7) chokidar: specifier: ^4.0.3 version: 4.0.3 cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: specifier: ^1.8.1 version: 1.8.1 @@ -1934,11 +2018,11 @@ importers: vendor/include: dependencies: '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.4 - version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: specifier: ^1.8.1 version: 1.8.1 @@ -1949,17 +2033,20 @@ importers: vendor/loader: dependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: specifier: ^1.8.1 version: 1.8.1 + node-addon-require-builtin: + specifier: ^0.1.0 + version: 0.1.0 vendor/logger-console: dependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: specifier: ^1.8.1 version: 1.8.1 @@ -1982,8 +2069,8 @@ importers: vendor/timer: dependencies: cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: specifier: ^1.8.1 version: 1.8.1 @@ -2298,10 +2385,14 @@ packages: '@cordisjs/plugin-loader': ^1.0.0-rc.4 cordis: ^4.0.0-rc.5 - '@cordisjs/plugin-loader@1.0.0-rc.4': - resolution: {integrity: sha512-pocUsZiZ/r2yOJby79tmn22Ifk3tCpOmHNYar4TPAotSja30soSrnMVU8YIRD/vJdjuDCMnM/46nDQWDFLM3SQ==} + '@cordisjs/plugin-loader@1.0.0-rc.5': + resolution: {integrity: sha512-084Wn2SzkFinbaASTq8blHOUqQt/oxZfX6gnrt0lnJ1CrystulFLL1+XVgF4o7lUMN9bHn4cfT1pMtkHprCtHw==} peerDependencies: - cordis: ^4.0.0-rc.5 + cordis: ^4.0.0-rc.7 + node-addon-require-builtin: ^0.1.0 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true '@cordisjs/plugin-timer@1.1.2': resolution: {integrity: sha512-5z5C3Eewt8JzK9XGy5JgIoYFRqXPWZnT7hHFfuJMQNzSom6iEVeLXpYiMvqVqGfJicHA7IroaOjcLRf99sidrQ==} @@ -2752,8 +2843,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iconify-json/simple-icons@1.2.89': - resolution: {integrity: sha512-hRaCY5s2G5oWAIhc4LCGYn6g6RrwLL4zhoLOT+KUO3joVCxVlZKA+839bv/47Nbe9/ZD4UA6dznZ4XPYcI53wA==} + '@iconify-json/simple-icons@1.2.90': + resolution: {integrity: sha512-zt2o2ZvQpHVvZJARIkZ51RnaHY2oqcPJMvHE+mVnxkSr+c33fnX4gciiXu+wyX5ei+s0qbVX1wD0DWBbaGBYMA==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -3586,6 +3677,9 @@ packages: '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} @@ -3953,12 +4047,12 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cordis@4.0.0-rc.6: - resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} + cordis@4.0.0-rc.7: + resolution: {integrity: sha512-5nm6ehrSfJhEUV659CctEvyNuBY/AXapw8+ZEw7YENztdzpiT+Ha8nIfkyhfyAgPtJns9aB5On5nzl9Sm6zHeQ==} hasBin: true peerDependencies: '@cordisjs/plugin-include': ^1.0.4 - '@cordisjs/plugin-loader': ^1.0.0-rc.4 + '@cordisjs/plugin-loader': ^1.0.0-rc.5 peerDependenciesMeta: '@cordisjs/plugin-include': optional: true @@ -4968,6 +5062,58 @@ packages: resolution: {integrity: sha512-c5qopltRonjW6+VinXYMp4FVi9Sxf8eQEb/9G82EksQQ+JC4CDprv+ko5URWmtyZ3wD4GFdeRTyw1AG5yCwJhQ==} engines: {node: '>=20'} + node-addon-native-custom-loader@0.1.0: + resolution: {integrity: sha512-LtkRZWBshiGdWB9K7yuQuEeQaoYfWSFMwV52wi4kKsQSRCSjB4Sf4lEgQTiOlVmOznM0Bg9EABLKBowkCDucQQ==} + engines: {node: '>=20'} + + node-addon-require-builtin-darwin-arm64@0.1.0: + resolution: {integrity: sha512-KXmOO2gs5um5HXt8k9sZMnNwrgTdnRKOf5NMXLyrY6pc3QCJrdCY6J6STgurjoM4GXOJlfo2emEfOxrIS0sCbg==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + node-addon-require-builtin-darwin-x64@0.1.0: + resolution: {integrity: sha512-m1JkvBslC4ooNUvlvQoOx96d0qk2M1e+bO2gVkl+TT0SD07VGAPXNkxZvyT+O3A63i/1j0rjJ88B78sSdyDiVA==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + node-addon-require-builtin-linux-arm64-gnu@0.1.0: + resolution: {integrity: sha512-76fYWMzYBeT6eunBUrxAUleyMZwQfp8FgB3XLDCrQAsDtH0UVdg+zgmbVIQeJyJQdxTnfsQ9sfvdymG96ZeZew==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-x64-gnu@0.1.0: + resolution: {integrity: sha512-dDOumCPgJheVfcHOVq2nCQUp3mRU0Qsu6MfnZzVZMA73tSeQwA+yoUuQW3oPz/wuE51LwXEkm6Se9aerawi0Ng==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-win32-arm64-msvc@0.1.0: + resolution: {integrity: sha512-OJ7m8r074Wbtc8mLsh+ugIP4KCwsTyDzfB7FE+7eeSSYRgQlbSOC11jMOYIWqMalLhAWCLkRBw7fYJDty3sSAw==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + node-addon-require-builtin-win32-ia32-msvc@0.1.0: + resolution: {integrity: sha512-qUhC7MEP0NhuNMwlnPudYIBtPKlUo9McRi3PWsv4539hFar1RfxGmU4DZYtDQk07Bms/NAlIE8X7mxKVu8E+OQ==} + engines: {node: '>=20 <23'} + cpu: [ia32] + os: [win32] + + node-addon-require-builtin-win32-x64-msvc@0.1.0: + resolution: {integrity: sha512-JHiuwzW6jz6K8UxzoFmthDCUyZcbXlPIim0LuH7rlgz7ebVW7791lJThZp4WYGHWMiHzhljEfVG9YW4DuEwEmA==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + node-addon-require-builtin@0.1.0: + resolution: {integrity: sha512-HGlhjpNtFP7qtbBIBQ2+eXDe1qXcX4RQa426IMQ+SKoLCQS9AcHYl0kwJCERvG821wfRlJOzGBoREtBOwvUGeg==} + engines: {node: '>=20'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6098,29 +6244,31 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6)': + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7)': dependencies: - '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) - cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) + cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: 1.8.1 js-yaml: 4.2.0 - '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6)': + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7)': dependencies: '@cordisjs/plugin-loader': link:vendor/loader - cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) cosmokit: 1.8.1 js-yaml: 4.2.0 optional: true - '@cordisjs/plugin-loader@1.0.0-rc.4(cordis@4.0.0-rc.6)': + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0)': dependencies: - cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: 1.8.1 + optionalDependencies: + node-addon-require-builtin: 0.1.0 - '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.6)': + '@cordisjs/plugin-timer@1.1.2(cordis@4.0.0-rc.7)': dependencies: - cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + cordis: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) cosmokit: 1.8.1 '@csstools/color-helpers@6.1.0': {} @@ -6430,7 +6578,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iconify-json/simple-icons@1.2.89': + '@iconify-json/simple-icons@1.2.90': dependencies: '@iconify/types': 2.0.0 @@ -7077,6 +7225,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/jsdom@28.0.3': dependencies: '@types/node': 25.9.3 @@ -7495,23 +7645,23 @@ snapshots: dependencies: is-what: 5.5.0 - cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4): + cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5): dependencies: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) - '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.5)(cordis@4.0.0-rc.7) + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) - cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): + cordis@4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): dependencies: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 optionalDependencies: - '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.7) '@cordisjs/plugin-loader': link:vendor/loader - cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): + cordis@4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): dependencies: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 @@ -8767,6 +8917,55 @@ snapshots: node-addon-landlock-run-linux-arm64: 0.0.0-test.0 node-addon-landlock-run-linux-x64: 0.0.0-test.0 + node-addon-native-custom-loader@0.1.0: {} + + node-addon-require-builtin-darwin-arm64@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-darwin-x64@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-linux-arm64-gnu@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-linux-x64-gnu@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-win32-arm64-msvc@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-win32-ia32-msvc@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin-win32-x64-msvc@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optional: true + + node-addon-require-builtin@0.1.0: + dependencies: + node-addon-native-custom-loader: 0.1.0 + optionalDependencies: + node-addon-require-builtin-darwin-arm64: 0.1.0 + node-addon-require-builtin-darwin-x64: 0.1.0 + node-addon-require-builtin-linux-arm64-gnu: 0.1.0 + node-addon-require-builtin-linux-x64-gnu: 0.1.0 + node-addon-require-builtin-win32-arm64-msvc: 0.1.0 + node-addon-require-builtin-win32-ia32-msvc: 0.1.0 + node-addon-require-builtin-win32-x64-msvc: 0.1.0 + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -9352,7 +9551,7 @@ snapshots: dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) - '@iconify-json/simple-icons': 1.2.89 + '@iconify-json/simple-icons': 1.2.90 '@shikijs/core': 2.5.0 '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f2e7fe7c07..07182c9959 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,7 @@ allowBuilds: # need, so we deny them — install still succeeds. '@google/genai': false protobufjs: false + node-addon-require-builtin: false # The Landlock launcher family is our own sibling-repo release, consumed # fresh (hours old at each coordinated bump) — the release-age quarantine @@ -32,3 +33,7 @@ minimumReleaseAgeExclude: - node-addon-landlock-run - node-addon-landlock-run-linux-arm64 - node-addon-landlock-run-linux-x64 + # Cordis release candidates are source-vendored and pinned in vendor/README.md + # during the same-day sync that updates package manifests and the lockfile. + - '@cordisjs/plugin-loader@1.0.0-rc.5' + - cordis@4.0.0-rc.7 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index a03f7ca25a..80957af255 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -2,7 +2,7 @@ "AGENTS.md": 1370, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, - "docs/cordis-primer.md": 550, + "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, "examples/AGENTS.md": 200, diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 468a46e904..a6660f7cd3 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -5,7 +5,7 @@ * `--check` verifies the generated set. */ -import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import ts from 'typescript' import { collectEvents, collectServices } from './gen-cordis-catalog.ts' @@ -15,6 +15,7 @@ import { graphNodeId as nodeId, type PackageGraphNode, } from './package-graph.ts' +import { TypeScriptProject } from './ts-project.ts' const root = resolve(import.meta.dirname, '..') type Pkg = PackageGraphNode @@ -45,6 +46,14 @@ interface EventRelation { listeners: Set } +interface PackageSource { + rel: string + pkg: string + sourceFile: ts.SourceFile +} + +type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' + const GROUP_ORDER = [ 'util', 'llm', @@ -242,51 +251,6 @@ const SERVICE_ROLES: ServiceRole[] = [ }, ] -const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [ - // Creation notifications preserve synchronous veto/rollback but observe - // returned promises explicitly so async listener rejection is not unhandled. - { event: 'agent/created', pkg: 'agent', method: 'events.dispatch' }, - // Registry disposal reuses the stable carrier captured before entry commit - // and contains each listener directly rather than rebuilding via agentEvents. - { event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' }, - { event: 'session/created', pkg: 'session', method: 'events.dispatch' }, - // Session event callbacks are likewise resolved before the log push, then - // invoked individually after commit so observer failures are contained. - { event: 'session/event', pkg: 'session', method: 'events.dispatch' }, - // Flush resolves the scoped callback set directly so internal instrumentation - // cannot substitute the accepted session before parallel invocation. - { event: 'session/flush', pkg: 'session', method: 'events.dispatch' }, - // Session disposal uses direct callback resolution so teardown contains each - // synchronous throw and returned-promise rejection independently. - { event: 'session/disposed', pkg: 'session', method: 'events.dispatch' }, - // tools/result uses ctx.events.dispatch directly so the registry can invoke - // every synchronous observer while containing each callback independently. - { event: 'tools/result', pkg: 'tools', method: 'events.dispatch' }, - // Subagent lifecycle events intentionally bypass ctx.emit and call - // ctx.events.dispatch directly so one throwing listener cannot starve later - // listeners or strand an already-started child run. - { event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' }, - { event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' }, - // provider-removed fires inside the provider registration's DISPOSER and - // routes through the same contained dispatch (see emitLifecycle in - // dsh-subagent), so the AST scan cannot attribute it either. - { event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' }, - // The workflow/* lifecycle events dispatch the same way, for the same - // per-listener-containment reason (WorkflowService.emitWorkflowEvent). - { event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' }, - { event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' }, - { event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' }, - { event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' }, - { event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' }, - { event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' }, -] - -const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [ - // The invariants oracle marks the session started from its global - // internal/dispatch listener before product session-start callbacks run. - { event: 'agent/session-start', pkg: 'invariants' }, -] - function generatedHeader(title: string): string[] { return [ '