From d2f810e9fe44c9484d0b889f0908f6e319d08341 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 15:38:47 +0800 Subject: [PATCH 01/28] feat(docs): build maintainable documentation site --- docs/AGENTS.md | 1 + docs/rfc/INDEX.md | 1 + ...026-07-13-documentation-site-projection.md | 39 + .../user}/zh-CN/develop/basic/config.md | 2 +- .../user}/zh-CN/develop/basic/index.md | 6 +- .../user}/zh-CN/develop/basic/tool.md | 2 +- .../user}/zh-CN/develop/framework/events.md | 2 +- .../user}/zh-CN/develop/framework/index.md | 4 +- .../user}/zh-CN/develop/framework/service.md | 16 +- .../user}/zh-CN/develop/practice/index.md | 4 +- .../zh-CN/develop/practice/llm-adapter.md | 0 docs/user/zh-CN/guide/config.md | 57 + {website => docs/user}/zh-CN/guide/index.md | 6 +- .../user}/zh-CN/guide/quickstart.md | 13 +- {website => docs/user}/zh-CN/index.md | 4 +- eslint.config.mjs | 5 +- knip.json | 10 + package.json | 9 +- pnpm-lock.yaml | 1565 ++++++++++++++++- pnpm-workspace.yaml | 1 + scripts/project-doc-site.spec.ts | 98 ++ scripts/project-doc-site.ts | 215 +++ scripts/run-gates.ts | 1 + scripts/verify-md-wrap.ts | 24 +- tsconfig.json | 4 +- vitest.config.ts | 2 +- website/.gitignore | 5 +- website/.vitepress/config.ts | 142 ++ website/.vitepress/config/index.ts | 17 - website/.vitepress/config/zh-CN.ts | 99 -- website/docs.ts | 213 +++ website/package.json | 15 +- website/zh-CN/api/cordis/context.md | 85 - website/zh-CN/api/cordis/events.md | 120 -- website/zh-CN/api/cordis/fiber.md | 108 -- website/zh-CN/api/cordis/registry.md | 87 - website/zh-CN/api/cordis/service.md | 97 - website/zh-CN/api/harness/agent.md | 85 - website/zh-CN/api/harness/bash.md | 81 - website/zh-CN/api/harness/fs.md | 78 - website/zh-CN/api/harness/llm.md | 124 -- website/zh-CN/api/harness/session.md | 56 - website/zh-CN/api/harness/subagent.md | 85 - website/zh-CN/api/harness/tools.md | 122 -- website/zh-CN/api/index.md | 25 - website/zh-CN/design/composability.md | 72 - website/zh-CN/design/context-model.md | 129 -- website/zh-CN/design/effects-coeffects.md | 69 - website/zh-CN/design/index.md | 39 - website/zh-CN/design/reactive-coeffects.md | 90 - website/zh-CN/design/revertible-effects.md | 128 -- website/zh-CN/guide/config.md | 342 ---- 52 files changed, 2380 insertions(+), 2224 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md rename {website => docs/user}/zh-CN/develop/basic/config.md (96%) rename {website => docs/user}/zh-CN/develop/basic/index.md (95%) rename {website => docs/user}/zh-CN/develop/basic/tool.md (98%) rename {website => docs/user}/zh-CN/develop/framework/events.md (97%) rename {website => docs/user}/zh-CN/develop/framework/index.md (95%) rename {website => docs/user}/zh-CN/develop/framework/service.md (83%) rename {website => docs/user}/zh-CN/develop/practice/index.md (95%) rename {website => docs/user}/zh-CN/develop/practice/llm-adapter.md (100%) create mode 100644 docs/user/zh-CN/guide/config.md rename {website => docs/user}/zh-CN/guide/index.md (89%) rename {website => docs/user}/zh-CN/guide/quickstart.md (84%) rename {website => docs/user}/zh-CN/index.md (90%) create mode 100644 scripts/project-doc-site.spec.ts create mode 100644 scripts/project-doc-site.ts create mode 100644 website/.vitepress/config.ts delete mode 100644 website/.vitepress/config/index.ts delete mode 100644 website/.vitepress/config/zh-CN.ts create mode 100644 website/docs.ts delete mode 100644 website/zh-CN/api/cordis/context.md delete mode 100644 website/zh-CN/api/cordis/events.md delete mode 100644 website/zh-CN/api/cordis/fiber.md delete mode 100644 website/zh-CN/api/cordis/registry.md delete mode 100644 website/zh-CN/api/cordis/service.md delete mode 100644 website/zh-CN/api/harness/agent.md delete mode 100644 website/zh-CN/api/harness/bash.md delete mode 100644 website/zh-CN/api/harness/fs.md delete mode 100644 website/zh-CN/api/harness/llm.md delete mode 100644 website/zh-CN/api/harness/session.md delete mode 100644 website/zh-CN/api/harness/subagent.md delete mode 100644 website/zh-CN/api/harness/tools.md delete mode 100644 website/zh-CN/api/index.md delete mode 100644 website/zh-CN/design/composability.md delete mode 100644 website/zh-CN/design/context-model.md delete mode 100644 website/zh-CN/design/effects-coeffects.md delete mode 100644 website/zh-CN/design/index.md delete mode 100644 website/zh-CN/design/reactive-coeffects.md delete mode 100644 website/zh-CN/design/revertible-effects.md delete mode 100644 website/zh-CN/guide/config.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6824c71cc6..53f7d2f5b0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,6 +15,7 @@ Every fact has exactly one home — the tier whose job it is — and every other | [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | +| [user/](user/zh-CN/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 8dc3b93b47..7a44b009ad 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -160,6 +160,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Raise the Node LTS engine floor to 22.19](implemented/process/2026-07-06-node-engine-floor.md) | 2026-07-06 | | [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 | | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 | +| [Project canonical documentation into the website](implemented/process/2026-07-13-documentation-site-projection.md) | 2026-07-13 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md b/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md new file mode 100644 index 0000000000..5bdb0f87c0 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-13-documentation-site-projection.md @@ -0,0 +1,39 @@ +# RFC: Project canonical documentation into the website + +Status: implemented + +## Problem + +The repository needs a navigable documentation website without turning the website directory into a second documentation source. Copying package guides, architecture pages, or generated catalogs into a site-specific tree allows the two copies to drift, while pointing VitePress directly at the repository root couples public URLs and navigation to the internal file layout. Repository-relative links also need different destinations on the website: published pages stay inside the site, but source files and unpublished contributor documents belong on GitHub. + +## Decision + +Canonical Markdown remains in the repository tier that owns it. Product-facing guides live under `docs/user/`, generated reference remains in the existing generated catalogs, and architectural and cookbook pages remain at their existing `docs/` paths. + +`website/docs.ts` is an explicit publication manifest. Each entry maps one canonical source file to a stable public route, sidebar, section, and order. Adding or removing a published page is therefore a reviewable manifest change rather than an implicit directory crawl. + +`scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. + +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. + +Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. + +Site publication is separate from site construction. The repository contains local development and build commands, but no hosting or deployment workflow until a public destination is chosen. + +## Alternatives considered + +**Commit copied Markdown under `website/`.** This makes VitePress setup direct, but every copied guide or API table gains two owners and requires a synchronization convention that cannot identify which copy is authoritative. + +**Make `website/` the canonical home for every published page.** This keeps one copy but moves architecture, generated reference, and contributor-facing material away from their repository ownership tiers merely to satisfy a renderer. + +**Discover every Markdown file automatically.** This minimizes manifest maintenance but publishes internal documents accidentally, exposes source moves as URL changes, and produces navigation from incidental directory order. + +**Use filesystem symlinks.** Symlinks preserve a single source but do not solve public routing or repository-relative links, and their behavior is less predictable across local development, package tooling, and hosted CI environments. + +**Build only in a deployment workflow.** A deployment job can reveal rendering failures after merge. Keeping the production build in `doc-sync` makes the same failure visible locally and in ordinary CI even when no public deployment exists. + +## Consequences + +Documentation facts have one editable home, public routes remain stable across source moves, and the site can include generated references without committing another generated copy. Local development watches canonical inputs and regenerates the disposable projection. + +The publication manifest is a maintained allowlist, and link projection adds a small repository-specific build adapter. A new kind of Markdown link behavior needs a projector test. Mermaid support also increases the client bundle size, but preserves diagrams already used by the canonical documentation. diff --git a/website/zh-CN/develop/basic/config.md b/docs/user/zh-CN/develop/basic/config.md similarity index 96% rename from website/zh-CN/develop/basic/config.md rename to docs/user/zh-CN/develop/basic/config.md index 49bcc4ca77..6c5bf6c651 100644 --- a/website/zh-CN/develop/basic/config.md +++ b/docs/user/zh-CN/develop/basic/config.md @@ -105,4 +105,4 @@ export function apply(ctx: Context, config: Config) { ## 下一步 - [插件与生命周期](../framework/) — 深入了解插件的完整生命周期 -- [服务与依赖](../framework/service) — 让你的插件对外提供服务 +- [服务与依赖](../framework/service.md) — 让你的插件对外提供服务 diff --git a/website/zh-CN/develop/basic/index.md b/docs/user/zh-CN/develop/basic/index.md similarity index 95% rename from website/zh-CN/develop/basic/index.md rename to docs/user/zh-CN/develop/basic/index.md index 71d6962edd..ce68283892 100644 --- a/website/zh-CN/develop/basic/index.md +++ b/docs/user/zh-CN/develop/basic/index.md @@ -115,7 +115,7 @@ export default class MyService extends Service { } ``` -大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service))。 +大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service.md))。 ## 完整示例 @@ -144,5 +144,5 @@ export function apply(ctx: Context) { ## 下一步 -- [开发一个 Tool](./tool) — 详细了解 tool 定义 DSL -- [插件配置](./config) — 让插件接受用户配置 +- [开发一个 Tool](./tool.md) — 详细了解 tool 定义 DSL +- [插件配置](./config.md) — 让插件接受用户配置 diff --git a/website/zh-CN/develop/basic/tool.md b/docs/user/zh-CN/develop/basic/tool.md similarity index 98% rename from website/zh-CN/develop/basic/tool.md rename to docs/user/zh-CN/develop/basic/tool.md index 96d58da78d..47a3af7867 100644 --- a/website/zh-CN/develop/basic/tool.md +++ b/docs/user/zh-CN/develop/basic/tool.md @@ -195,5 +195,5 @@ export function apply(ctx: Context) { ## 下一步 -- [插件配置](./config) — 让你的 tool 可配置 +- [插件配置](./config.md) — 让你的 tool 可配置 - [能力三件套](../practice/) — 了解 seam/impl/consumer 模式 diff --git a/website/zh-CN/develop/framework/events.md b/docs/user/zh-CN/develop/framework/events.md similarity index 97% rename from website/zh-CN/develop/framework/events.md rename to docs/user/zh-CN/develop/framework/events.md index 0546fd68e7..641c63b0f8 100644 --- a/website/zh-CN/develop/framework/events.md +++ b/docs/user/zh-CN/develop/framework/events.md @@ -149,4 +149,4 @@ export function apply(ctx: Context) { ## 下一步 - [能力三件套](../practice/) — 事件在 capability seam 中的角色 -- [LLM 适配器](../practice/llm-adapter) — 实现一个完整的 LLM 后端 +- [LLM 适配器](../practice/llm-adapter.md) — 实现一个完整的 LLM 后端 diff --git a/website/zh-CN/develop/framework/index.md b/docs/user/zh-CN/develop/framework/index.md similarity index 95% rename from website/zh-CN/develop/framework/index.md rename to docs/user/zh-CN/develop/framework/index.md index 8d2f7c2b8a..b0547be61b 100644 --- a/website/zh-CN/develop/framework/index.md +++ b/docs/user/zh-CN/develop/framework/index.md @@ -135,5 +135,5 @@ effect cleaned up ## 下一步 -- [服务与依赖](./service) — 让你的插件对外提供能力 -- [事件系统](./events) — 插件间通信的核心机制 +- [服务与依赖](./service.md) — 让你的插件对外提供能力 +- [事件系统](./events.md) — 插件间通信的核心机制 diff --git a/website/zh-CN/develop/framework/service.md b/docs/user/zh-CN/develop/framework/service.md similarity index 83% rename from website/zh-CN/develop/framework/service.md rename to docs/user/zh-CN/develop/framework/service.md index 08d9a1b2c8..17b9cb4e4e 100644 --- a/website/zh-CN/develop/framework/service.md +++ b/docs/user/zh-CN/develop/framework/service.md @@ -127,21 +127,11 @@ export const inject = { optional: ['metrics'] } `plugin-a` 和 `plugin-b` 各自看到自己组内的 bash 实例,互不影响。 -## Harness 内置服务一览 +## Harness 内置服务 -| 服务名 | 提供者 | 用途 | -|--------|--------|------| -| `tools` | dsh-tools | Tool 注册表 | -| `llm` | dsh-llm | LLM 调用 + 适配器注册 | -| `agents` | dsh-agent | Agent 实例管理 | -| `session` | dsh-session | 会话事件流 | -| `systemPrompt` | dsh-system-prompt | 系统提示词组装 | -| `bash` | dsh-bash-local | Bash 命令执行 | -| `fs` | dsh-fs-local | 文件系统操作 | -| `subagent` | dsh-subagent | 子代理委派 | -| `persistence` | dsh-session-persistence | 会话持久化 | +服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 ## 下一步 -- [事件系统](./events) — 插件间松耦合通信 +- [事件系统](./events.md) — 插件间松耦合通信 - [能力三件套](../practice/) — 服务在 seam 模式中的应用 diff --git a/website/zh-CN/develop/practice/index.md b/docs/user/zh-CN/develop/practice/index.md similarity index 95% rename from website/zh-CN/develop/practice/index.md rename to docs/user/zh-CN/develop/practice/index.md index dd0ec1cb60..bffa35f964 100644 --- a/website/zh-CN/develop/practice/index.md +++ b/docs/user/zh-CN/develop/practice/index.md @@ -58,7 +58,7 @@ | 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | | Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | | 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | -| 压缩 | `dsh-compact` | `dsh-compact-basic` | (内置于 agent-loop) | +| 压缩 | `dsh-compact` | `dsh-compact-basic` | 由实现插件消费 agent-loop 的扩展事件 | ## 开发你自己的三件套 @@ -153,4 +153,4 @@ export function apply(ctx: Context) { ## 下一步 -- [LLM 适配器](./llm-adapter) — 实现一个 LLM 后端(最常见的 seam 扩展) +- [LLM 适配器](./llm-adapter.md) — 实现一个 LLM 后端(最常见的 seam 扩展) diff --git a/website/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md similarity index 100% rename from website/zh-CN/develop/practice/llm-adapter.md rename to docs/user/zh-CN/develop/practice/llm-adapter.md diff --git a/docs/user/zh-CN/guide/config.md b/docs/user/zh-CN/guide/config.md new file mode 100644 index 0000000000..5e13b69d9f --- /dev/null +++ b/docs/user/zh-CN/guide/config.md @@ -0,0 +1,57 @@ +# 配置文件 + +Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 + +## 从真实配置开始 + +仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: + +- [echo-agent](../../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 +- [coding-agent](../../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [acp-agent](../../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 + +最小配置由一组插件条目组成: + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## 插件条目 + +`name` 指定 npm 包或相对于 `cordis.yml` 的本地模块,`id` 为插件实例提供稳定标识,`config` 传入插件自己的配置。需要临时跳过某个条目时可设置 `disabled: true`。 + +```yaml +- id: local-tool + name: './src/my-tool.ts' + disabled: false + config: + toolName: my_tool +``` + +插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 + +## JavaScript 值和环境变量 + +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 + +```yaml +config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + cwd: !!js process.cwd() +``` + +标签是 `!!js`,不是 `!js`。 + +## 精确配置参考 + +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../../architecture.md)和[能力接口](../../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../../examples/README.md)中最接近的例子。 diff --git a/website/zh-CN/guide/index.md b/docs/user/zh-CN/guide/index.md similarity index 89% rename from website/zh-CN/guide/index.md rename to docs/user/zh-CN/guide/index.md index 8b7211b308..8c7f7e603a 100644 --- a/website/zh-CN/guide/index.md +++ b/docs/user/zh-CN/guide/index.md @@ -28,7 +28,7 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 2. 填写 API key 3. 运行 -不需要写任何代码。详见 [快速开始](./quickstart)。 +不需要写任何代码。详见 [快速开始](./quickstart.md)。 ### 插件开发者 @@ -41,7 +41,7 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 ## 技术栈 -- **运行时**: Node.js >= 24 +- **运行时**: Node.js ^22.19 或 >= 24 - **语言**: TypeScript (ESM) - **框架**: Cordis -- **包管理**: pnpm workspaces +- **包管理**: pnpm workspaces(仓库固定使用 pnpm 11) diff --git a/website/zh-CN/guide/quickstart.md b/docs/user/zh-CN/guide/quickstart.md similarity index 84% rename from website/zh-CN/guide/quickstart.md rename to docs/user/zh-CN/guide/quickstart.md index f15ac182cf..7ca4b19332 100644 --- a/website/zh-CN/guide/quickstart.md +++ b/docs/user/zh-CN/guide/quickstart.md @@ -4,13 +4,14 @@ ## 环境准备 -- [Node.js](https://nodejs.org/) >= 24 -- [pnpm](https://pnpm.io/) >= 9 +- [Node.js](https://nodejs.org/) ^22.19 或 >= 24 +- [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) ```sh # 确认版本 -node -v # v24.x 或更高 -pnpm -v # 9.x 或更高 +node -v # v22.19.x,或 v24.x 及更高版本 +corepack enable +pnpm -v # 11.x ``` ## 第一步:运行 echo-agent @@ -24,8 +25,6 @@ cd deepseek-harness # 安装依赖 pnpm install -# 如果看到 ERR_PNPM_IGNORED_BUILDS,可以忽略——安装已经成功了。 -# 想消除这个提示可以跑一次: pnpm approve-builds # 启动 echo-agent pnpm run demo:echo @@ -94,5 +93,5 @@ echo-agent 和 coding-agent 用的是同一个应用框架(`@deepseek-ai/dsh-std ## 下一步 -- [配置文件](./config) — 了解 `cordis.yml` 的完整语法 +- [配置文件](./config.md) — 了解 `cordis.yml` 的完整语法 - [开发插件](../develop/basic/) — 编写你自己的 tool 或后端 diff --git a/website/zh-CN/index.md b/docs/user/zh-CN/index.md similarity index 90% rename from website/zh-CN/index.md rename to docs/user/zh-CN/index.md index 90b23e483a..cbf700e41e 100644 --- a/website/zh-CN/index.md +++ b/docs/user/zh-CN/index.md @@ -7,10 +7,10 @@ hero: actions: - theme: brand text: 快速开始 - link: /zh-CN/guide/quickstart + link: /guide/quickstart - theme: alt text: 开发插件 - link: /zh-CN/develop/basic/ + link: /develop/basic/ features: - title: 插件化架构 details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。 diff --git a/eslint.config.mjs b/eslint.config.mjs index c62d3e9739..ab4c78ccb0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,7 @@ export default tseslint.config( '**/.sessions/**', '.claude/**', // harness-local state (worktrees, skills) — other checkouts, not this one's sources '**/.doc-typecheck-*/**', + 'website/.generated/**', 'vendor/**', // vendored source keeps upstream style and idioms '**/*.js', '**/*.mjs', @@ -33,7 +34,7 @@ export default tseslint.config( // --- our packages: full strictness ------------------------------------- { - files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], + files: ['packages/*/*/src/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], @@ -125,7 +126,7 @@ export default tseslint.config( // --- formatting (everything we own) ------------------------------------- { - files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'], + files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts', 'eslint.config.mjs'], plugins: { '@stylistic': stylistic }, rules: { '@stylistic/indent': ['error', 2], diff --git a/knip.json b/knip.json index cf43b90e34..88199c02a4 100644 --- a/knip.json +++ b/knip.json @@ -15,6 +15,16 @@ ], "project": ["scripts/**/*.ts", "examples/**/*.ts"] }, + "website": { + "project": ["**/*.ts"], + "ignoreDependencies": [ + "@braintree/sanitize-url", + "cytoscape", + "cytoscape-cose-bilkent", + "dayjs", + "debug" + ] + }, "packages/*/*": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/package.json b/package.json index 0fc57b7db5..3a1e49391c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "workspaces": [ "vendor/*", - "packages/*/*" + "packages/*/*", + "website" ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", @@ -44,6 +45,10 @@ "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", + "docs:dev": "pnpm --filter @deepseek-ai/website run dev", + "docs:build": "pnpm --filter @deepseek-ai/website run build", + "docs:preview": "pnpm --filter @deepseek-ai/website run preview", + "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts && pnpm run docs:build", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", @@ -62,7 +67,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "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-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && 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", + "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-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && 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 docs:check", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bc2ed1495..360ddc7e7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,31 +75,6 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - packages/ui/user-approval: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@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 - 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) - packages/bash/bash: devDependencies: '@deepseek-ai/dsh-brand': @@ -164,9 +139,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -194,6 +166,9 @@ importers: '@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) @@ -425,9 +400,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../ui/user-approval '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../../code-runtime/code-runtime @@ -440,6 +412,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt + '@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) @@ -1135,9 +1110,6 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../user-approval '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -1186,6 +1158,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction @@ -1310,6 +1285,31 @@ importers: 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) + packages/ui/user-approval: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@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 + 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) + packages/ui/user-interaction: devDependencies: '@deepseek-ai/dsh-agent': @@ -1654,6 +1654,36 @@ importers: specifier: ^1.8.1 version: 1.8.1 + website: + devDependencies: + '@braintree/sanitize-url': + specifier: 7.1.2 + version: 7.1.2 + cytoscape: + specifier: 3.34.0 + version: 3.34.0 + cytoscape-cose-bilkent: + specifier: 4.1.0 + version: 4.1.0(cytoscape@3.34.0) + dayjs: + specifier: 1.11.21 + version: 1.11.21 + debug: + specifier: 4.4.3 + version: 4.4.3 + mermaid: + specifier: 11.16.0 + version: 11.16.0 + vite: + specifier: ^5.4.14 + version: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3)) + packages: '@agentclientprotocol/sdk@0.25.1': @@ -1661,6 +1691,82 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@algolia/abtesting@1.21.2': + resolution: {integrity: sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.55.2': + resolution: {integrity: sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.55.2': + resolution: {integrity: sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.55.2': + resolution: {integrity: sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.55.2': + resolution: {integrity: sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.55.2': + resolution: {integrity: sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.55.2': + resolution: {integrity: sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.55.2': + resolution: {integrity: sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.55.2': + resolution: {integrity: sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.55.2': + resolution: {integrity: sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.55.2': + resolution: {integrity: sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.55.2': + resolution: {integrity: sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.55.2': + resolution: {integrity: sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.55.2': + resolution: {integrity: sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw==} + engines: {node: '>= 14.0.0'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -1839,6 +1945,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -1901,6 +2010,29 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@earendil-works/pi-ai@0.79.3': resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} engines: {node: '>=22.19.0'} @@ -1924,102 +2056,204 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -2032,6 +2266,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -2044,6 +2284,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -2056,24 +2302,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -2148,6 +2418,9 @@ 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/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -2167,6 +2440,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} @@ -2645,6 +2921,168 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -2811,6 +3249,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/jsdom@28.0.3': resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} @@ -2820,9 +3261,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2847,6 +3297,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@8.61.0': resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2906,9 +3359,19 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.8': resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: @@ -2947,6 +3410,94 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2964,6 +3515,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + algoliasearch@5.55.2: + resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} + engines: {node: '>= 14.0.0'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -2998,6 +3553,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -3022,6 +3580,12 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -3029,6 +3593,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -3040,6 +3607,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + cordis@4.0.0-rc.6: resolution: {integrity: sha512-GzUv7zCKh3FlgM3/Ad2S03UpYO3v4u1GcKa7ig4K2je4lCrgJ/S64ziiZI6XNyKEa1tZwdzj4oBQrhYDLgfEiA==} hasBin: true @@ -3069,6 +3640,9 @@ packages: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: @@ -3290,10 +3864,17 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3304,6 +3885,11 @@ packages: es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -3363,6 +3949,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3428,6 +4017,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -3479,6 +4071,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -3489,6 +4090,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3538,6 +4142,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3783,6 +4391,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3818,6 +4429,9 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -3918,6 +4532,12 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3958,10 +4578,16 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + openai@6.26.0: resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} hasBin: true @@ -4024,6 +4650,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4041,10 +4670,21 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.4: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} @@ -4068,6 +4708,15 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4079,6 +4728,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -4111,6 +4763,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -4134,6 +4791,9 @@ packages: schemastery@3.18.0: resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -4147,6 +4807,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4158,12 +4821,22 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -4174,6 +4847,10 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4185,6 +4862,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4219,6 +4899,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -4324,6 +5007,9 @@ packages: unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -4340,11 +5026,48 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-tsconfig-paths@6.1.1: resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4388,6 +5111,24 @@ packages: yaml: optional: true + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4429,6 +5170,14 @@ packages: jsdom: optional: true + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4516,6 +5265,118 @@ snapshots: dependencies: zod: 4.4.3 + '@algolia/abtesting@1.21.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)': + dependencies: + '@algolia/client-search': 5.55.2 + algoliasearch: 5.55.2 + + '@algolia/client-abtesting@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-analytics@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-common@5.55.2': {} + + '@algolia/client-insights@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-personalization@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-query-suggestions@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/client-search@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/ingestion@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/monitoring@1.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/recommend@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + + '@algolia/requester-browser-xhr@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-fetch@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + + '@algolia/requester-node-http@5.55.2': + dependencies: + '@algolia/client-common': 5.55.2 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -4815,6 +5676,9 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@6.0.4': + optional: true + '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -4864,6 +5728,31 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3) + preact: 10.29.7 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.55.2)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.55.2)(algoliasearch@5.55.2) + '@docsearch/css': 3.8.2 + algoliasearch: 5.55.2 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -4916,81 +5805,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -5053,6 +6011,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/simple-icons@1.2.89': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.3': @@ -5075,6 +6037,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -5359,6 +6332,121 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -5566,6 +6654,10 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/jsdom@28.0.3': dependencies: '@types/node': 25.9.3 @@ -5577,10 +6669,19 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@22.20.0': @@ -5602,6 +6703,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5693,11 +6796,18 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.3': {} + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3))': + dependencies: + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5761,6 +6871,105 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@6.0.3) + + '@vue/shared@3.5.39': {} + + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.39(typescript@6.0.3) + transitivePeerDependencies: + - typescript + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5776,6 +6985,23 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + algoliasearch@5.55.2: + dependencies: + '@algolia/abtesting': 1.21.2 + '@algolia/client-abtesting': 5.55.2 + '@algolia/client-analytics': 5.55.2 + '@algolia/client-common': 5.55.2 + '@algolia/client-insights': 5.55.2 + '@algolia/client-personalization': 5.55.2 + '@algolia/client-query-suggestions': 5.55.2 + '@algolia/client-search': 5.55.2 + '@algolia/ingestion': 1.55.2 + '@algolia/monitoring': 1.55.2 + '@algolia/recommend': 5.55.2 + '@algolia/requester-browser-xhr': 5.55.2 + '@algolia/requester-fetch': 5.55.2 + '@algolia/requester-node-http': 5.55.2 + ansis@4.3.1: {} anynum@1.0.0: {} @@ -5806,6 +7032,8 @@ snapshots: bignumber.js@9.3.1: {} + birpc@2.9.0: {} + birpc@4.0.0: {} bowser@2.14.1: {} @@ -5822,18 +7050,28 @@ snapshots: chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + character-entities@2.0.2: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 + comma-separated-tokens@2.0.3: {} + commander@7.2.0: {} commander@8.3.0: {} convert-source-map@2.0.0: {} + copy-anything@4.0.5: + 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): dependencies: '@standard-schema/spec': 1.1.0 @@ -5871,6 +7109,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): dependencies: cose-base: 1.0.3 @@ -6106,14 +7346,44 @@ snapshots: dependencies: safe-buffer: 5.2.1 + emoji-regex-xs@1.0.0: {} + empathic@2.0.1: {} + entities@7.0.1: {} + entities@8.0.0: {} es-module-lexer@2.1.0: {} es-toolkit@1.49.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -6219,6 +7489,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -6280,6 +7552,10 @@ snapshots: flatted@3.4.2: {} + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -6338,6 +7614,26 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0: @@ -6348,6 +7644,8 @@ snapshots: html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6388,6 +7686,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-what@5.5.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -6620,6 +7920,8 @@ snapshots: dependencies: semver: 7.8.4 + mark.js@8.11.1: {} + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -6710,6 +8012,18 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -6947,6 +8261,10 @@ snapshots: dependencies: brace-expansion: 5.0.6 + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -6974,8 +8292,17 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + non-layered-tidy-tree-layout@2.0.2: + optional: true + obug@2.1.3: {} + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + openai@6.26.0(ws@8.21.0)(zod@4.4.3): optionalDependencies: ws: 8.21.0 @@ -7068,6 +8395,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -7085,8 +8414,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.7: {} + prelude-ls@1.2.1: {} + property-information@7.2.0: {} + protobufjs@7.6.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7116,12 +8449,24 @@ snapshots: readdirp@4.1.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} retry@0.13.1: {} + rfdc@1.4.1: {} + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): @@ -7182,6 +8527,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.1 '@rolldown/binding-win32-x64-msvc': 1.1.1 + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -7208,6 +8584,8 @@ snapshots: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 + search-insights@2.17.3: {} + semver@7.8.4: {} shebang-command@2.0.0: @@ -7216,16 +8594,36 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} smol-toml@1.6.1: {} source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + stackback@0.0.2: {} std-env@4.1.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -7234,6 +8632,10 @@ snapshots: stylis@4.4.0: {} + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -7242,6 +8644,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -7269,6 +8673,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -7352,6 +8758,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -7373,6 +8783,16 @@ snapshots: uuid@14.0.1: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: debug: 4.4.3 @@ -7383,6 +8803,16 @@ snapshots: - supports-color - typescript + vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + lightningcss: 1.32.0 + vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -7413,6 +8843,63 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vitepress-plugin-mermaid@2.0.17(mermaid@11.16.0)(vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3)): + dependencies: + mermaid: 11.16.0 + vitepress: 1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@1.6.4(@algolia/client-search@5.55.2)(@types/node@25.9.3)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.3): + 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 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.9.3)(lightningcss@1.32.0))(vue@3.5.39(typescript@6.0.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.39 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.9.3)(lightningcss@1.32.0) + vue: 3.5.39(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + vitest@4.1.8(@types/node@22.20.0)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 @@ -7471,6 +8958,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.39(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@6.0.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 6.0.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6407a99f52..3572d7368a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - vendor/* - packages/*/* + - website peerDependencyRules: allowedVersions: diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts new file mode 100644 index 0000000000..f44b779d80 --- /dev/null +++ b/scripts/project-doc-site.spec.ts @@ -0,0 +1,98 @@ +/** Tests for the documentation website projection adapter. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { DocsPage } from '../website/docs.ts' +import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function fixture(): { root: string; pages: DocsPage[] } { + const root = mkdtempSync(join(tmpdir(), 'dsh-doc-site-')) + roots.push(root) + mkdirSync(join(root, 'docs'), { recursive: true }) + mkdirSync(join(root, 'packages'), { recursive: true }) + writeFileSync(join(root, 'docs/a.md'), '# A\n') + writeFileSync(join(root, 'docs/b.md'), '# B\n') + writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n') + writeFileSync(join(root, 'packages/logo.svg'), '\n') + return { + root, + pages: [ + { source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-docs', section: 'Test', order: 1 }, + { source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-docs', section: 'Test', order: 2 }, + ], + } +} + +describe('rewriteMarkdown', () => { + it('maps published pages and pins unpublished source links', () => { + const { root, pages } = fixture() + const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n' + expect(rewriteMarkdown(source, { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe( + '[B](./reference/b.md#part) ' + + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + + '[web](https://example.com)\n', + ) + }) + + it('uses raw GitHub content for unpublished images', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') + }) + + it('does not rewrite Markdown-looking text inside code fences', () => { + const { root, pages } = fixture() + const source = '```md\n[B](b.md)\n```\n' + expect(rewriteMarkdown(source, { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe(source) + }) + + it('fails loud when a relative target is missing', () => { + const { root, pages } = fixture() + expect(() => rewriteMarkdown('[missing](missing.md)\n', { + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toThrow('links to missing path "missing.md"') + }) +}) + +describe('addProjectionFrontmatter', () => { + it('adds frontmatter to an ordinary Markdown page', () => { + expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe( + '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n', + ) + }) + + it('extends existing VitePress frontmatter', () => { + expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe( + '---\neditSource: "docs/index.md"\nlayout: home\n---\n', + ) + }) +}) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts new file mode 100644 index 0000000000..89e35dfdc8 --- /dev/null +++ b/scripts/project-doc-site.ts @@ -0,0 +1,215 @@ +/** + * Build-time projection from canonical repository Markdown into VitePress. + * + * The generated tree is disposable: sources stay in their owning `docs/` + * tier, while this adapter rewrites cross-source links for the public site. + */ + +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, extname, posix, relative, resolve, sep } from 'node:path' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' +import { docsPages, type DocsPage } from '../website/docs.ts' + +const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' +const root = resolve(import.meta.dirname, '..') +const generatedRoot = resolve(root, 'website/.generated') + +interface Replacement { + start: number + end: number + value: string +} + +/** Inputs for rewriting one canonical Markdown page. */ +export interface RewriteMarkdownOptions { + sourcePath: string + route: string + pages: DocsPage[] + repoRoot: string + repositoryRef: string +} + +function repoPath(absPath: string, repoRoot: string): string { + return relative(repoRoot, absPath).split(sep).join('/') +} + +function isExternalOrSiteAbsolute(url: string): boolean { + return url.startsWith('#') + || url.startsWith('//') + || url.startsWith('/') + || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) +} + +function splitTarget(url: string): { path: string; suffix: string } { + const boundary = url.search(/[?#]/) + if (boundary === -1) return { path: url, suffix: '' } + return { path: url.slice(0, boundary), suffix: url.slice(boundary) } +} + +function decodePath(path: string): string { + try { + return decodeURIComponent(path) + } catch { + throw new Error(`project-doc-site: malformed percent escape in ${JSON.stringify(path)}.`) + } +} + +function routeTarget(fromRoute: string, toRoute: string, suffix: string): string { + const target = posix.relative(posix.dirname(fromRoute), toRoute) + return `${target.startsWith('.') ? target : `./${target}`}${suffix}` +} + +function sourceMap(pages: DocsPage[]): Map { + const map = new Map() + for (const page of pages) { + for (const source of [page.source, ...(page.sourceAliases ?? [])]) { + if (map.has(source)) { + throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)}.`) + } + map.set(source, page) + } + } + return map +} + +function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } { + const decoded = decodePath(rawPath) + let absPath = resolve(dirname(sourceAbs), decoded) + if (existsSync(absPath)) return { absPath } + + const lineMatch = decoded.match(/:(\d+)$/) + if (lineMatch !== null) { + const lineText = lineMatch[1] + if (lineText === undefined) throw new Error('project-doc-site: line suffix matched without a line number.') + absPath = resolve(dirname(sourceAbs), decoded.slice(0, -lineMatch[0].length)) + if (existsSync(absPath)) return { absPath, line: Number.parseInt(lineText, 10) } + } + + if (extname(decoded) === '') { + const markdown = resolve(dirname(sourceAbs), `${decoded}.md`) + if (existsSync(markdown)) return { absPath: markdown } + const index = resolve(dirname(sourceAbs), decoded, 'index.md') + if (existsSync(index)) return { absPath: index } + } + + throw new Error(`project-doc-site: ${repoPath(sourceAbs, repoRoot)} links to missing path ${JSON.stringify(rawPath)}.`) +} + +function githubTarget( + absPath: string, + line: number | undefined, + suffix: string, + repositoryRef: string, + repoRoot: string, + image: boolean, +): string { + const path = repoPath(absPath, repoRoot) + if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}` + const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' + const lineSuffix = line === undefined ? suffix : `#L${line}` + return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` +} + +/** + * Rewrite repository-relative links without reserializing Markdown. + * + * @param source Markdown text from the canonical file. + * @param options Source, route, manifest, and repository context. + * @returns Markdown whose published links resolve inside the site or to GitHub. + */ +export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions): string { + const sourceAbs = resolve(options.repoRoot, options.sourcePath) + const published = sourceMap(options.pages) + const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const replacements: Replacement[] = [] + + const rewrite = (node: Nodes & { url: string }): void => { + if (isExternalOrSiteAbsolute(node.url)) return + const { path, suffix } = splitTarget(node.url) + if (path === '') return + const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) + const targetPath = repoPath(absPath, options.repoRoot) + const page = published.get(targetPath) + const nextUrl = page === undefined + ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') + : routeTarget(options.route, page.route, suffix) + + const start = node.position?.start.offset + const end = node.position?.end.offset + if (start === undefined || end === undefined) { + throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) + } + const rawNode = source.slice(start, end) + const urlOffset = rawNode.lastIndexOf(node.url) + if (urlOffset === -1) { + throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`) + } + replacements.push({ + start: start + urlOffset, + end: start + urlOffset + node.url.length, + value: nextUrl, + }) + } + + const visit = (node: Nodes): void => { + if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) rewrite(node) + if ('children' in node) { + for (const child of node.children) visit(child) + } + } + visit(tree) + + let projected = source + for (const replacement of replacements.sort((a, b) => b.start - a.start)) { + projected = projected.slice(0, replacement.start) + replacement.value + projected.slice(replacement.end) + } + return projected +} + +/** + * Record the canonical edit target in VitePress frontmatter. + * + * @param markdown Projected Markdown content. + * @param sourcePath Repository-relative canonical source path. + * @returns Markdown with an `editSource` frontmatter field. + */ +export function addProjectionFrontmatter(markdown: string, sourcePath: string): string { + const field = `editSource: ${JSON.stringify(sourcePath)}` + if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`) + return `---\n${field}\n---\n\n${markdown}` +} + +/** Canonical Markdown files watched by the local VitePress dev server. */ +export function docsSourceFiles(): string[] { + return [...new Set(docsPages.map(page => resolve(root, page.source)))] +} + +/** Rebuild the disposable VitePress source tree from the publication manifest. */ +export function projectDocs(): void { + const routes = new Set() + const repositoryRef = process.env.GITHUB_SHA ?? 'master' + rmSync(generatedRoot, { recursive: true, force: true }) + + for (const page of docsPages) { + if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`) + routes.add(page.route) + const sourceAbs = resolve(root, page.source) + if (!existsSync(sourceAbs) || !lstatSync(sourceAbs).isFile()) { + throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`) + } + const output = resolve(generatedRoot, page.route) + mkdirSync(dirname(output), { recursive: true }) + const markdown = readFileSync(sourceAbs, 'utf8') + const projected = rewriteMarkdown(markdown, { + sourcePath: page.source, + route: page.route, + pages: docsPages, + repoRoot: root, + repositoryRef, + }) + writeFileSync(output, addProjectionFrontmatter(projected, page.source)) + } +} diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ef1e07ea43..c9c9013100 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -272,6 +272,7 @@ function docSyncLeafGates(): Gate[] { pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), + pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), ] } diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 3143ee5617..65bf35fb03 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -10,10 +10,12 @@ * `paragraph` node whose source span covers more than one line. The parser owns * all the structure that legitimately occupies multiple lines — fenced code * (any fence length), tables, list items, blockquotes, HTML blocks, headings, - * thematic breaks, link-reference definitions — so a hard wrap is simply "a - * paragraph node that starts and ends on different lines." This is checker, not - * formatter: it reports and never rewrites, so it introduces zero cosmetic - * churn (no emphasis-marker or table-delimiter normalization). + * thematic breaks, link-reference definitions — while a small preprocessing + * pass masks VitePress YAML frontmatter and custom-container delimiter lines. + * A hard wrap is simply "a paragraph node that starts and ends on different + * lines." This is checker, not formatter: it reports and never rewrites, so it + * introduces zero cosmetic churn (no emphasis-marker or table-delimiter + * normalization). * * A wrapped paragraph inside a list item or blockquote is still a `paragraph` * node, so those are caught too. Scope mirrors doc-typecheck plus the two @@ -57,11 +59,23 @@ interface Violation { text: string } +function maskVitePressStructure(source: string): string { + const lines = source.split('\n') + if (lines[0] === '---') { + const closing = lines.indexOf('---', 1) + if (closing !== -1) { + for (let index = 0; index <= closing; index++) lines[index] = '' + } + } + return lines.map(line => line.trimStart().startsWith(':::') ? '' : line).join('\n') +} + /** Find every hard-wrapped prose paragraph in one Markdown file via its AST. */ function findViolations(absPath: string): Violation[] { const file = relative(root, absPath) const source = readFileSync(absPath, 'utf8') - const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) + const parsedSource = maskVitePressStructure(source) + const tree = fromMarkdown(parsedSource, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) const out: Violation[] = [] const visit = (node: Nodes): void => { diff --git a/tsconfig.json b/tsconfig.json index 70780e1c17..800fd39318 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,9 @@ "examples/*/start.ts", "examples/*/tests/**/*.ts", "packages/*/*/tests/**/*.ts", - "scripts/**/*.ts" + "scripts/**/*.ts", + "website/**/*.ts", + "website/.vitepress/**/*.ts" ], "references": [ { "path": "./vendor/cosmokit" }, diff --git a/vitest.config.ts b/vitest.config.ts index 11d1454b08..459147f4a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ // instead applies the one root map to every importer. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { - include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], + include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'], coverage: { provider: 'v8', // Coverage measures OUR runtime source. Types-only files carry no diff --git a/website/.gitignore b/website/.gitignore index 2c1fa99cb4..29099c8fe6 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -1,3 +1,4 @@ node_modules/ -.vitepress/dist/ -.vitepress/cache/ +.cache/ +.dist/ +.generated/ diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts new file mode 100644 index 0000000000..71c276794a --- /dev/null +++ b/website/.vitepress/config.ts @@ -0,0 +1,142 @@ +/** VitePress configuration for the locally projected documentation site. */ + +import type { DefaultTheme, PageData } from 'vitepress' +import type { ViteDevServer } from 'vite' +import { withMermaid } from 'vitepress-plugin-mermaid' +import { docsPages, type DocsPage } from '../docs.ts' +import { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts' + +projectDocs() + +const sectionOrder = [ + '入门', + '基础', + '框架能力', + '实战', + 'Concepts', + 'Generated reference', + 'Data structures', + 'Cookbook', +] + +function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { + const pages = docsPages.filter(page => page.sidebar === collection && page.route !== 'index.md') + const sections = new Map() + for (const page of pages) { + const entries = sections.get(page.section) ?? [] + entries.push(page) + sections.set(page.section, entries) + } + return [...sections.entries()] + .sort(([left], [right]) => sectionOrder.indexOf(left) - sectionOrder.indexOf(right)) + .map(([text, entries]) => ({ + text, + items: entries + .sort((left, right) => left.order - right.order) + .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), + })) +} + +function watchCanonicalDocs(server: ViteDevServer): void { + const sources = docsSourceFiles() + server.watcher.add(sources) + server.watcher.on('change', (changed) => { + if (!sources.includes(changed)) return + projectDocs() + }) +} + +function escapeVueInterpolation(html: string): string { + return html.replaceAll('{{', '{{').replaceAll('}}', '}}') +} + +const sharedTheme: Pick = { + search: { provider: 'local' }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, + ], + editLink: { + pattern: ({ frontmatter }: PageData) => { + const data: unknown = frontmatter + const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined + if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') + return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + }, + text: '在 GitHub 上编辑此页', + }, +} + +export default withMermaid({ + title: 'DeepSeek Harness', + description: '用于构建 Agent Harness 的插件化 SDK', + cleanUrls: true, + srcDir: '.generated', + cacheDir: '.cache', + outDir: '.dist', + locales: { + root: { + label: '简体中文', + lang: 'zh-CN', + themeConfig: { + nav: [ + { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, + { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, + { text: 'Reference', link: '/en/', activeMatch: '^/en/' }, + ], + sidebar: { + '/guide/': sidebar('zh-guide'), + '/develop/': sidebar('zh-develop'), + }, + outline: { label: '本页目录' }, + docFooter: { prev: '上一篇', next: '下一篇' }, + }, + }, + en: { + label: 'English', + lang: 'en-US', + link: '/en/', + themeConfig: { + nav: [ + { text: 'Concepts', link: '/en/' }, + { text: 'Reference', link: '/en/config-catalog' }, + { text: '中文指南', link: '/guide/' }, + ], + sidebar: { + '/en/': sidebar('en-docs'), + }, + editLink: { + pattern: ({ frontmatter }: PageData) => { + const data: unknown = frontmatter + const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined + if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') + return `https://github.com/deepseek-harness/deepseek-harness/edit/master/${editSource}` + }, + text: 'Edit this page on GitHub', + }, + outline: { label: 'On this page' }, + docFooter: { prev: 'Previous', next: 'Next' }, + }, + }, + }, + vite: { + plugins: [ + { + name: 'deepseek-harness-doc-projector', + configureServer: watchCanonicalDocs, + }, + ], + }, + markdown: { + config(md) { + const renderText = md.renderer.rules.text + const renderCode = md.renderer.rules.code_inline + if (renderText === undefined || renderCode === undefined) { + throw new Error('VitePress Markdown renderer is missing its text or inline-code rule.') + } + md.renderer.rules.text = (...args) => escapeVueInterpolation(renderText(...args)) + md.renderer.rules.code_inline = (...args) => escapeVueInterpolation(renderCode(...args)) + }, + }, + mermaid: {}, + themeConfig: sharedTheme, +}) diff --git a/website/.vitepress/config/index.ts b/website/.vitepress/config/index.ts deleted file mode 100644 index b4978ca4aa..0000000000 --- a/website/.vitepress/config/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'vitepress' -import { zhCN } from './zh-CN' - -export default defineConfig({ - title: 'DeepSeek Harness', - description: '插件化 Agent 开发框架', - - locales: { - 'zh-CN': zhCN, - }, - - themeConfig: { - socialLinks: [ - { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, - ], - }, -}) diff --git a/website/.vitepress/config/zh-CN.ts b/website/.vitepress/config/zh-CN.ts deleted file mode 100644 index 83767b6cbc..0000000000 --- a/website/.vitepress/config/zh-CN.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress' - -const guideSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '入门', - items: [ - { text: '介绍', link: '/zh-CN/guide/' }, - { text: '快速开始', link: '/zh-CN/guide/quickstart' }, - { text: '配置文件', link: '/zh-CN/guide/config' }, - ], - }, -] - -const developSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '基础', - items: [ - { text: '第一个插件', link: '/zh-CN/develop/basic/' }, - { text: '开发一个 Tool', link: '/zh-CN/develop/basic/tool' }, - { text: '插件配置', link: '/zh-CN/develop/basic/config' }, - ], - }, - { - text: '框架能力', - items: [ - { text: '插件与生命周期', link: '/zh-CN/develop/framework/' }, - { text: '服务与依赖', link: '/zh-CN/develop/framework/service' }, - { text: '事件系统', link: '/zh-CN/develop/framework/events' }, - ], - }, - { - text: '实战', - items: [ - { text: '能力的三层拆分', link: '/zh-CN/develop/practice/' }, - { text: 'LLM 适配器', link: '/zh-CN/develop/practice/llm-adapter' }, - ], - }, -] - -const apiSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '框架 API', - items: [ - { text: '总览', link: '/zh-CN/api/' }, - { text: 'Context', link: '/zh-CN/api/cordis/context' }, - { text: 'Events', link: '/zh-CN/api/cordis/events' }, - { text: 'Fiber', link: '/zh-CN/api/cordis/fiber' }, - { text: 'Registry', link: '/zh-CN/api/cordis/registry' }, - { text: 'Service', link: '/zh-CN/api/cordis/service' }, - ], - }, - { - text: 'Harness API', - items: [ - { text: 'Tools (dsh-tools)', link: '/zh-CN/api/harness/tools' }, - { text: 'LLM (dsh-llm)', link: '/zh-CN/api/harness/llm' }, - { text: 'Session (dsh-session)', link: '/zh-CN/api/harness/session' }, - { text: 'Agent (dsh-agent)', link: '/zh-CN/api/harness/agent' }, - { text: 'Bash (dsh-bash)', link: '/zh-CN/api/harness/bash' }, - { text: 'Filesystem (dsh-fs)', link: '/zh-CN/api/harness/fs' }, - { text: 'Subagent (dsh-subagent)', link: '/zh-CN/api/harness/subagent' }, - ], - }, -] - -const designSidebar: DefaultTheme.SidebarItem[] = [ - { - text: '系统设计', - items: [ - { text: '概述', link: '/zh-CN/design/' }, - { text: '可组合性与插件系统', link: '/zh-CN/design/composability' }, - { text: '作用与余作用', link: '/zh-CN/design/effects-coeffects' }, - { text: '可逆作用', link: '/zh-CN/design/revertible-effects' }, - { text: '响应式余作用', link: '/zh-CN/design/reactive-coeffects' }, - { text: '上下文模型', link: '/zh-CN/design/context-model' }, - ], - }, -] - -export const zhCN: LocaleSpecificConfig = { - label: '简体中文', - lang: 'zh-CN', - themeConfig: { - nav: [ - { text: '入门', link: '/zh-CN/guide/', activeMatch: '/zh-CN/guide/' }, - { text: '开发', link: '/zh-CN/develop/basic/', activeMatch: '/zh-CN/develop/' }, - { text: 'API', link: '/zh-CN/api/', activeMatch: '/zh-CN/api/' }, - { text: '设计', link: '/zh-CN/design/', activeMatch: '/zh-CN/design/' }, - ], - sidebar: { - '/zh-CN/guide/': guideSidebar, - '/zh-CN/develop/': developSidebar, - '/zh-CN/api/': apiSidebar, - '/zh-CN/design/': designSidebar, - }, - outline: { label: '本页目录' }, - docFooter: { prev: '上一篇', next: '下一篇' }, - }, -} diff --git a/website/docs.ts b/website/docs.ts new file mode 100644 index 0000000000..53e74c2905 --- /dev/null +++ b/website/docs.ts @@ -0,0 +1,213 @@ +/** + * Canonical publication manifest for the documentation website. + * + * Markdown stays in its owning repository tier. This manifest only maps a + * source file to its public route and navigation placement. + */ + +/** A page projected into the VitePress source tree. */ +export interface DocsPage { + /** Repository-relative canonical Markdown source. */ + source: string + /** VitePress route, including the `.md` suffix. */ + route: string + /** Navigation label shown in the sidebar. */ + label: string + /** Sidebar collection that owns the page. */ + sidebar: 'zh-guide' | 'zh-develop' | 'en-docs' + /** Section label within the sidebar. */ + section: string + /** Stable order within the section. */ + order: number + /** Additional repository paths that resolve to this page. */ + sourceAliases?: string[] +} + +const zhGuide: DocsPage[] = [ + { + source: 'docs/user/zh-CN/index.md', + route: 'index.md', + label: 'DeepSeek Harness', + sidebar: 'zh-guide', + section: '入门', + order: 0, + }, + { + source: 'docs/user/zh-CN/guide/index.md', + route: 'guide/index.md', + label: '介绍', + sidebar: 'zh-guide', + section: '入门', + order: 1, + sourceAliases: ['docs/user/zh-CN/guide'], + }, + { + source: 'docs/user/zh-CN/guide/quickstart.md', + route: 'guide/quickstart.md', + label: '快速开始', + sidebar: 'zh-guide', + section: '入门', + order: 2, + }, + { + source: 'docs/user/zh-CN/guide/config.md', + route: 'guide/config.md', + label: '配置文件', + sidebar: 'zh-guide', + section: '入门', + order: 3, + }, +] + +const zhDevelop: DocsPage[] = [ + { + source: 'docs/user/zh-CN/develop/basic/index.md', + route: 'develop/basic/index.md', + label: '第一个插件', + sidebar: 'zh-develop', + section: '基础', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/basic'], + }, + { + source: 'docs/user/zh-CN/develop/basic/tool.md', + route: 'develop/basic/tool.md', + label: '开发一个 Tool', + sidebar: 'zh-develop', + section: '基础', + order: 2, + }, + { + source: 'docs/user/zh-CN/develop/basic/config.md', + route: 'develop/basic/config.md', + label: '插件配置', + sidebar: 'zh-develop', + section: '基础', + order: 3, + }, + { + source: 'docs/user/zh-CN/develop/framework/index.md', + route: 'develop/framework/index.md', + label: '插件与生命周期', + sidebar: 'zh-develop', + section: '框架能力', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/framework'], + }, + { + source: 'docs/user/zh-CN/develop/framework/service.md', + route: 'develop/framework/service.md', + label: '服务与依赖', + sidebar: 'zh-develop', + section: '框架能力', + order: 2, + }, + { + source: 'docs/user/zh-CN/develop/framework/events.md', + route: 'develop/framework/events.md', + label: '事件系统', + sidebar: 'zh-develop', + section: '框架能力', + order: 3, + }, + { + source: 'docs/user/zh-CN/develop/practice/index.md', + route: 'develop/practice/index.md', + label: '能力的三层拆分', + sidebar: 'zh-develop', + section: '实战', + order: 1, + sourceAliases: ['docs/user/zh-CN/develop/practice'], + }, + { + source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', + route: 'develop/practice/llm-adapter.md', + label: 'LLM 适配器', + sidebar: 'zh-develop', + section: '实战', + order: 2, + }, +] + +const enOverview: DocsPage[] = ([ + ['docs/architecture.md', 'en/index.md', 'Architecture'], + ['docs/cordis-primer.md', 'en/cordis-primer.md', 'Cordis primer'], + ['docs/capability-seams.md', 'en/capability-seams.md', 'Capability services'], + ['docs/agent-lifecycle.md', 'en/agent-lifecycle.md', 'Agent lifecycle'], + ['docs/tool-execution-pipeline.md', 'en/tool-execution-pipeline.md', 'Tool execution'], +] as const).map(([source, route, label], order) => ({ + source, + route, + label, + sidebar: 'en-docs', + section: 'Concepts', + order, +})) + +const enCatalogs: DocsPage[] = ([ + ['docs/config-catalog.md', 'en/config-catalog.md', 'Plugin configuration'], + ['docs/tool-catalog.md', 'en/tool-catalog.md', 'Tool schemas'], + ['docs/cordis-catalog/services.md', 'en/cordis-catalog/services.md', 'Services'], + ['docs/cordis-catalog/events.md', 'en/cordis-catalog/events.md', 'Events'], + ['docs/persistence-catalog.md', 'en/persistence-catalog.md', 'Persistence events'], +] as const).map(([source, route, label], order) => ({ + source, + route, + label, + sidebar: 'en-docs', + section: 'Generated reference', + order, +})) + +const corePages = [ + ['core.md', 'Core data structures'], + ['session.md', 'Sessions'], + ['tools.md', 'Tools'], + ['llm-streaming.md', 'LLM streaming'], + ['bash.md', 'Bash execution'], + ['filesystem.md', 'Filesystem'], + ['code-runtime.md', 'Code runtime'], + ['compaction.md', 'Compaction'], + ['subagent.md', 'Subagents'], + ['workflow.md', 'Workflows'], + ['skills.md', 'Skills'], + ['approval.md', 'Approvals'], + ['user-interaction.md', 'User interaction'], + ['sandbox.md', 'Sandboxing'], + ['web.md', 'Web access'], + ['persistence.md', 'Session persistence'], +] as const + +const enCore: DocsPage[] = corePages.map(([file, label], order) => ({ + source: `docs/core-data-structures/${file}`, + route: `en/core-data-structures/${file}`, + label, + sidebar: 'en-docs', + section: 'Data structures', + order, + ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), +})) + +const enCookbook: DocsPage[] = ([ + ['adding-a-package.md', 'Adding a package'], + ['adding-a-tool.md', 'Adding a tool'], + ['adding-an-llm-adapter.md', 'Adding an LLM adapter'], + ['extension-cookbook.md', 'Extension patterns'], +] as const).map(([file, label], order) => ({ + source: `docs/cookbook/${file}`, + route: `en/cookbook/${file}`, + label, + sidebar: 'en-docs', + section: 'Cookbook', + order, +})) + +/** Every canonical page published by the documentation website. */ +export const docsPages: DocsPage[] = [ + ...zhGuide, + ...zhDevelop, + ...enOverview, + ...enCatalogs, + ...enCore, + ...enCookbook, +] diff --git a/website/package.json b/website/package.json index 33c32fae4c..a2279418bb 100644 --- a/website/package.json +++ b/website/package.json @@ -4,12 +4,19 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "vitepress dev . --port 5173 --open", + "dev": "vitepress dev . --host 127.0.0.1 --port 5173", "build": "vitepress build .", - "preview": "vitepress preview ." + "preview": "vitepress preview . --host 127.0.0.1 --port 4173" }, "devDependencies": { - "vitepress": "^1.6.3", - "vue": "^3.5.13" + "@braintree/sanitize-url": "7.1.2", + "cytoscape": "3.34.0", + "cytoscape-cose-bilkent": "4.1.0", + "dayjs": "1.11.21", + "debug": "4.4.3", + "mermaid": "11.16.0", + "vite": "^5.4.14", + "vitepress": "^1.6.4", + "vitepress-plugin-mermaid": "^2.0.17" } } diff --git a/website/zh-CN/api/cordis/context.md b/website/zh-CN/api/cordis/context.md deleted file mode 100644 index a18f275dad..0000000000 --- a/website/zh-CN/api/cordis/context.md +++ /dev/null @@ -1,85 +0,0 @@ -# Context - -上下文对象是 Cordis 的核心。所有服务、方法、属性都通过 `ctx` 访问。 - -## 服务与混入 - -Context 基于组合式 API 设计,大部分属性和方法挂载在服务上。以下是核心 API: - -- [`ctx.on`](./events#ctx-on) — 注册事件监听器 -- [`ctx.emit`](./events#ctx-emit) — 触发事件 -- [`ctx.bail`](./events#ctx-bail) — 短路事件 -- [`ctx.serial`](./events#ctx-serial) — 顺序异步事件 -- [`ctx.waterfall`](./events#ctx-waterfall) — 管道事件 -- [`ctx.effect`](./fiber#fiber-effect) — 注册可逆效果 -- [`ctx.plugin`](./registry#ctx-plugin) — 加载子插件 -- [`ctx.inject`](./registry#ctx-inject) — 获取依赖的插件 -- [`ctx.get`](#ctx-get) — 获取服务 -- [`ctx.set`](#ctx-set) — 设置服务 -- [`ctx.provide`](#ctx-provide) — 声明服务 - -## 实例属性 - -### ctx.fiber - -- **类型:** [`Fiber`](./fiber) - -当前上下文的作用域对象。 - -## 实例方法 - -### ctx.extend(meta) - -- **meta:** `object` -- **返回值:** `Context` - -构造一个以当前上下文为原型的新上下文实例。 - -### ctx.intercept(name, config) - -- **name:** `string` 服务名称 -- **config:** `object` 配置拦截 -- **返回值:** `Context` - -为指定服务添加一层配置拦截,返回新的上下文实例。 - -### ctx.isolate(name, label?) - -- **name:** `string` 服务名称 -- **label:** `symbol` 隔离域符号(可选) -- **返回值:** `Context` - -创建一个针对指定服务的隔离域,返回新的上下文实例。隔离域中的同名服务互不影响。 - -### ctx.get(name) - -- **name:** `string` 服务名称 -- **返回值:** `Service | undefined` - -获取指定名称的服务实例。 - -### ctx.set(name, value) - -- **name:** `string` 服务名称 -- **value:** `any` 服务值 - -设置指定名称的服务。 - -### ctx.provide(name, value?, options?) - -- **name:** `string` 服务名称 -- **value:** `any` 初始值(可选) -- **options:** `object` -- **返回值:** `void` - -声明一个服务。声明后其他插件可以通过 `inject` 依赖它。 - -## 静态属性 - -### Context.events - -内置事件服务的 symbol key。 - -### Context.current - -当前活跃的 Context 实例(在异步链中通过 AsyncLocalStorage 追踪)。 diff --git a/website/zh-CN/api/cordis/events.md b/website/zh-CN/api/cordis/events.md deleted file mode 100644 index dbc03a87bc..0000000000 --- a/website/zh-CN/api/cordis/events.md +++ /dev/null @@ -1,120 +0,0 @@ -# Events - -`ctx.events` 是内置服务,提供事件系统相关的全部 API。 - -## 实例方法 - -### ctx.on(event, listener, options?) {#ctx-on} - -- **event:** `string` 事件名称 -- **listener:** `Function` 事件监听器 -- **options:** `object` - - **prepend:** `boolean` 是否注册为前置(默认 `false`) - - **global:** `boolean` 是否注册为全局(默认 `false`) -- **返回值:** `() => void` 取消注册函数 - -注册一个事件监听器。返回的函数可用于手动取消注册,但通常不需要——插件卸载时会自动清理。 - -```typescript -ctx.on('agent/turn-end', (data) => { - console.log('turn ended:', data) -}) -``` - -### ctx.emit(thisArg?, event, ...args) {#ctx-emit} - -- **thisArg:** `any` 监听器的 `this` 参数(可选) -- **event:** `string` 事件名称 -- **args:** `any[]` 事件参数 -- **返回值:** `void` - -同步触发所有匹配的监听器(并行,不等待异步完成)。 - -### ctx.parallel(thisArg?, event, ...args) - -- 签名同 `emit` -- **返回值:** `Promise` - -异步触发所有匹配的监听器(并行等待)。 - -### ctx.bail(thisArg?, event, ...args) {#ctx-bail} - -- **返回值:** `any` - -同步依次触发监听器。第一个返回非 `undefined`/`null`/`false` 值的监听器停止链并返回该值。 - -### ctx.serial(thisArg?, event, ...args) {#ctx-serial} - -- **返回值:** `Promise` - -异步依次触发监听器。语义同 `bail` 的异步版本。 - -### ctx.waterfall(thisArg?, event, ...args) {#ctx-waterfall} - -- **返回值:** `Promise` - -管道模式:每个监听器接收前一个的输出。监听器内部必须调用 `next()` 才会传递给下一个。 - -```typescript -// 注册 -ctx.on('llm/pre-request', async (messages, next) => { - messages.push(extraMsg) - return next(messages) // 必须调用 -}) - -// 触发 -const result = await ctx.waterfall('llm/pre-request', initialMessages) -``` - -::: warning -不调用 `next()` 即为否决 (veto)——管道终止。这是设计行为,用于拦截/网关。 -::: - -## Harness 内置事件 - -### agent/pre-step - -- **触发模式:** serial -- **参数:** `{ agentId, turnIndex }` - -Agent 执行一步之前触发。 - -### agent/post-step - -- **触发模式:** emit -- **参数:** `{ agentId, turnIndex, blocks }` - -Agent 执行一步之后触发。 - -### tool/call - -- **触发模式:** emit -- **参数:** `{ name, args, callId }` - -Tool 被模型调用时触发。 - -### tool/result - -- **触发模式:** emit -- **参数:** `{ name, result, callId }` - -Tool 返回结果时触发。 - -### session/event - -- **触发模式:** emit -- **参数:** `SessionEvent` - -会话事件被记录时触发。 - -### compact/start - -- **触发模式:** emit - -上下文压缩开始。 - -### compact/end - -- **触发模式:** emit - -上下文压缩结束。 diff --git a/website/zh-CN/api/cordis/fiber.md b/website/zh-CN/api/cordis/fiber.md deleted file mode 100644 index ffb8f23bb5..0000000000 --- a/website/zh-CN/api/cordis/fiber.md +++ /dev/null @@ -1,108 +0,0 @@ -# Fiber - -Fiber(作用域)是插件实例的运行时容器,管理其生命周期和效果。 - -## 状态机 - -``` -PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED - ↘ FAILED -``` - -| 状态 | 数值 | 含义 | -|------|------|------| -| PENDING | 0 | 依赖未就绪,等待中 | -| LOADING | 1 | 正在执行 `apply` | -| ACTIVE | 2 | 运行中 | -| FAILED | 3 | `apply` 抛出异常 | -| UNLOADING | 4 | 正在撤销效果 | -| DISPOSED | 5 | 已完全卸载 | - -## 实例属性 - -### fiber.uid - -- **类型:** `number` - -Fiber 的唯一标识符。 - -### fiber.status - -- **类型:** `number` - -当前状态(见状态机)。 - -### fiber.config - -- **类型:** `object` - -传递给插件的配置对象。 - -### fiber.error - -- **类型:** `Error | undefined` - -如果状态是 FAILED,包含导致失败的异常。 - -## 实例方法 - -### fiber.effect(callback) {#fiber-effect} - -- **callback:** `() => (() => void) | void` -- **返回值:** `() => void` - -注册一个效果。`callback` 在 Fiber 激活时执行;如果返回函数,该函数在 Fiber dispose 时执行。 - -```typescript -ctx.effect(() => { - const timer = setInterval(tick, 1000) - return () => clearInterval(timer) -}) -``` - -等价地可以通过 `ctx.effect()` 调用(ctx 代理到当前 fiber)。 - -### fiber.dispose() - -- **返回值:** `Promise` - -手动 dispose 该 Fiber。按注册逆序撤销所有效果,递归 dispose 所有子 Fiber。 - -```typescript -const child = ctx.plugin(somePlugin) -// 之后: -await child.dispose() -``` - -### fiber.update(config) - -- **config:** `object` 新配置 -- **返回值:** `void` - -热更新配置。如果新旧配置不同,触发 dispose + 重新 apply。 - -### fiber.restart() - -- **返回值:** `void` - -强制重启:dispose 后重新加载。 - -### fiber.then(resolve, reject?) - -- **返回值:** `Promise` - -使 Fiber 可以被 `await`:等到状态进入 ACTIVE 或 FAILED。 - -```typescript -const fiber = ctx.plugin(myPlugin) -await fiber // 等待插件加载完成 -``` - -## 访问当前 Fiber - -```typescript -export function apply(ctx: Context) { - const fiber = ctx.fiber // 当前插件的 Fiber - console.log(fiber.status) // 1 (LOADING, 因为正在 apply 中) -} -``` diff --git a/website/zh-CN/api/cordis/registry.md b/website/zh-CN/api/cordis/registry.md deleted file mode 100644 index e0f66d8ed7..0000000000 --- a/website/zh-CN/api/cordis/registry.md +++ /dev/null @@ -1,87 +0,0 @@ -# Registry - -插件注册表,管理插件的加载和依赖解析。 - -## 实例方法 - -### ctx.plugin(plugin, config?) {#ctx-plugin} - -- **plugin:** `Plugin` 插件(函数、对象或类) -- **config:** `object` 传递给插件的配置(可选) -- **返回值:** `Fiber` - -加载一个子插件,返回其 Fiber。子 Fiber 的生命周期绑定到父上下文。 - -```typescript -// 函数插件 -ctx.plugin(myPlugin, { key: 'value' }) - -// 类插件 -ctx.plugin(MyService) - -// 返回的 Fiber 可以 await 或 dispose -const fiber = ctx.plugin(myPlugin) -await fiber -``` - -### ctx.inject(names, callback) {#ctx-inject} - -- **names:** `string[]` 服务名列表 -- **callback:** `(ctx: Context) => void` -- **返回值:** `() => void` - -等待指定服务全部就绪后执行 callback。如果服务消失,callback 的效果会自动撤销;服务恢复后重新执行。 - -```typescript -ctx.inject(['tools', 'llm'], (ctx) => { - // tools 和 llm 都就绪了 - ctx.tools.register(/* ... */) -}) -``` - -这是 `export const inject = [...]` 声明的底层 API。大多数情况下直接使用声明式写法即可。 - -## 插件形态 - -`ctx.plugin()` 接受三种插件形态: - -### 函数插件 - -```typescript -function myPlugin(ctx: Context, config?: Config) { - // ... -} -myPlugin.name = 'my-plugin' -myPlugin.inject = ['tools'] -``` - -### 对象插件 - -```typescript -const myPlugin = { - name: 'my-plugin', - inject: ['tools'], - apply(ctx: Context, config?: Config) { - // ... - }, -} -``` - -### 类插件(Service) - -```typescript -class MyService extends Service { - static inject = ['tools'] - constructor(ctx: Context) { - super(ctx, 'myService') - } -} -``` - -## 插件元信息 - -| 属性 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | 插件名称(日志用) | -| `inject` | `string[] \| { required?: string[], optional?: string[] }` | 依赖声明 | -| `Config` | `Schema \| object` | 配置 schema 或默认值 | diff --git a/website/zh-CN/api/cordis/service.md b/website/zh-CN/api/cordis/service.md deleted file mode 100644 index a57a00c461..0000000000 --- a/website/zh-CN/api/cordis/service.md +++ /dev/null @@ -1,97 +0,0 @@ -# Service - -Service 基类,用于创建对外暴露能力的插件。 - -## 基本用法 - -```typescript -import { Service, type Context } from 'cordis' - -declare module 'cordis' { - interface Context { - myService: MyService - } -} - -export default class MyService extends Service { - constructor(ctx: Context) { - super(ctx, 'myService') - } - - // 公开方法 - doSomething() { - // ... - } -} -``` - -加载后,其他插件可通过 `ctx.myService` 访问。 - -## 构造函数 - -### new Service(ctx, name) - -- **ctx:** `Context` 上下文 -- **name:** `string` 服务名(注册到 `ctx[name]`) - -## 实例属性 - -### service.ctx - -- **类型:** `Context` - -该服务绑定的上下文。 - -### service\[Service.tracker\] - -- **类型:** `object` - -服务追踪信息(名称、绑定状态等)。 - -## 生命周期 - -Service 子类可以覆写以下方法: - -### start() - -服务激活时调用。在这里初始化资源。 - -### stop() - -服务停用时调用。在这里释放资源。 - -## 静态属性 - -### Service.inject - -- **类型:** `string[] | { required?: string[], optional?: string[] }` - -声明本服务依赖的其他服务。 - -## 与 inject 的关系 - -当一个 Service 被加载: -1. 框架为该服务名创建声明 (`ctx.provide`) -2. 实例赋值到 `ctx[name]` -3. 依赖该服务的所有 Fiber 从 PENDING 转为 LOADING - -当 Service 被卸载: -1. `ctx[name]` 被置为 `undefined` -2. 依赖它的 Fiber 被 dispose -3. 当新的 provider 出现时,dependant Fiber 重新加载 - -## 示例:Harness 中的 Service - -```typescript -// dsh-tools 的 ToolRegistry 就是一个 Service -export class ToolRegistry extends Service { - constructor(ctx: Context) { - super(ctx, 'tools') - } - - register(tool: ToolDefinition): () => void { - // ...注册逻辑 - return dispose - } -} -``` diff --git a/website/zh-CN/api/harness/agent.md b/website/zh-CN/api/harness/agent.md deleted file mode 100644 index bf46c7e4e1..0000000000 --- a/website/zh-CN/api/harness/agent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Agent (dsh-agent) - -Agent 实例管理和生命周期。 - -**包名:** `@deepseek-ai/dsh-agent` -**服务名:** `ctx.agents` - -## Agent Service - -### ctx.agents.create(options) - -- **options:** `AgentOptions` -- **返回值:** `Agent` - -创建一个新的 Agent 实例。 - -### ctx.agents.get(id) - -- **id:** `AgentId` -- **返回值:** `Agent | undefined` - -获取指定 ID 的 Agent 实例。 - -## AgentOptions - -```typescript -interface AgentOptions { - /** Agent ID(branded) */ - id?: AgentId - /** 使用的模型名 */ - model: string - /** 系统提示词(支持 {{model}} 变量) */ - persona?: string - /** 关联的 session */ - session?: Session -} -``` - -## Agent 实例 - -### agent.id - -- **类型:** `AgentId` - -Agent 的唯一标识符(branded string)。 - -### agent.model - -- **类型:** `string` - -Agent 使用的模型名。 - -### agent.step(input) - -- **input:** `ContentBlock[]` -- **返回值:** `Promise` - -执行一步:将输入发送给模型,获取响应,执行 tool calls。这是 agent-loop 内部使用的核心方法。 - -## Agent Loop - -Agent 的执行循环由 `dsh-agent-loop` 管理。它: - -1. 组装 system prompt + 历史消息 + 当前输入 -2. 调用 LLM(通过 `ctx.llm`) -3. 解析响应中的 tool calls -4. 执行 tools -5. 将 tool results 追加到 session -6. 如果 finish reason 是 `tool-calls`,回到步骤 2 - -### 扩展点 - -- `agent/pre-step` 事件 — 在每一步 LLM 调用前触发 -- `agent/post-step` 事件 — 在每一步完成后触发 -- `llm/pre-request` waterfall — 可修改发送给模型的消息 - -## AgentId - -Opaque branded string: - -```typescript -import { AgentId } from '@deepseek-ai/dsh-agent' - -const id = AgentId('main') -``` diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md deleted file mode 100644 index 8e8d8d3068..0000000000 --- a/website/zh-CN/api/harness/bash.md +++ /dev/null @@ -1,81 +0,0 @@ -# Bash (dsh-bash) - -Bash 命令执行接口。 - -**接口包:** `@deepseek-ai/dsh-bash` -**实现:** `@deepseek-ai/dsh-bash-local` -**消费者:** `@deepseek-ai/dsh-tool-bash`(内置于 agent-core) - -## Bash Service - -### ctx.bash.execute(request) - -- **request:** `BashRequest` -- **返回值:** `Promise` - -执行一个 bash 命令。 - -## BashRequest - -```typescript -interface BashRequest { - /** 要执行的命令 */ - command: string - /** 工作目录 */ - workdir?: string - /** 超时时间 (ms) */ - timeoutMs?: number -} -``` - -## BashResult - -```typescript -interface BashResult { - /** 退出码 */ - exitCode: number - /** stdout 输出 */ - stdout: string - /** stderr 输出 */ - stderr: string - /** 是否超时 */ - timedOut: boolean -} -``` - -## 配置 (dsh-bash-local) - -```typescript -interface Config { - /** 命令超时时间,默认 120000 (2 分钟) */ - timeoutMs: number -} -``` - -在 `cordis.yml` 中: - -```yaml -- name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 -``` - -## 模型可用的 Tools - -`dsh-tool-bash` 向模型暴露以下 tools(由 `agent-core` 捆绑): - -| Tool | 说明 | -|------|------| -| `bash` | 执行命令(同步,等待完成) | -| `bash_output` | 获取后台命令的输出 | -| `bash_kill` | 终止后台命令 | - -## 设计模式 - -Bash 是 Harness 的"能力三件套"典型案例: - -- `dsh-bash`(接口):定义 `ctx.bash` 和 `BashRequest`/`BashResult` 类型 -- `dsh-bash-local`(实现):通过 `child_process.spawn` 在本地执行 -- `dsh-tool-bash`(消费者):将能力包装为模型可调用的 tool - -换一个沙箱执行器只需替换 `dsh-bash-local`,接口和 tool 不变。 diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md deleted file mode 100644 index 4e336ff962..0000000000 --- a/website/zh-CN/api/harness/fs.md +++ /dev/null @@ -1,78 +0,0 @@ -# Filesystem (dsh-fs) - -文件系统操作接口。 - -**接口包:** `@deepseek-ai/dsh-fs` -**实现:** `@deepseek-ai/dsh-fs-local` + `@deepseek-ai/dsh-fs-policy` -**消费者:** `@deepseek-ai/dsh-tool-fs` - -## FS Service - -### ctx.fs.read(path, options?) - -- **path:** `string` -- **options:** `{ offset?: number; limit?: number }` -- **返回值:** `Promise` - -读取文件内容。 - -### ctx.fs.write(path, content) - -- **path:** `string` -- **content:** `string` -- **返回值:** `Promise` - -写入文件(覆盖)。 - -### ctx.fs.edit(path, edits) - -- **path:** `string` -- **edits:** `Edit[]` -- **返回值:** `Promise` - -对文件执行精确的字符串替换编辑。 - -### ctx.fs.stat(path) - -- **path:** `string` -- **返回值:** `Promise` - -获取文件/目录信息。 - -## 配置 (dsh-fs-local) - -```typescript -interface Config { - /** 工作目录(相对路径的基准) */ - cwd: string -} -``` - -## 策略门 (dsh-fs-policy) - -`dsh-fs-policy` 是一个可选的中间层插件,实现 read-before-write/edit 策略——模型必须先读取文件才能写入或编辑。这防止模型盲目覆盖文件。 - -在 `cordis.yml` 中,它位于 `fs-local` 和 `tool-fs` 之间: - -```yaml -- name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() -- name: '@deepseek-ai/dsh-fs-policy' -- name: '@deepseek-ai/dsh-tool-fs' -``` - -## 模型可用的 Tools - -| Tool | 说明 | -|------|------| -| `read` | 读取文件内容(支持 offset/limit) | -| `write` | 写入文件(需要先 read) | -| `edit` | 精确字符串替换(需要先 read) | - -## 三件套结构 - -- `dsh-fs`:接口定义 -- `dsh-fs-local`:本地文件系统实现 -- `dsh-fs-policy`:策略门(read-before-write 检查) -- `dsh-tool-fs`:模型 tool 层 diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md deleted file mode 100644 index 82a4d8e225..0000000000 --- a/website/zh-CN/api/harness/llm.md +++ /dev/null @@ -1,124 +0,0 @@ -# LLM (dsh-llm) - -LLM 服务接口和适配器注册。 - -**包名:** `@deepseek-ai/dsh-llm` -**服务名:** `ctx.llm` - -## LLM Service - -### ctx.llm.registerAdapter(models, adapter) - -- **models:** `string[]` 该适配器支持的模型名列表 -- **adapter:** `LlmAdapter` 适配器实例 -- **返回值:** `() => void` disposer - -注册一个 LLM 适配器。当请求中指定的模型名在 `models` 列表中时,路由到该适配器。 - -```typescript -ctx.llm.registerAdapter(['deepseek-v4-flash', 'deepseek-v4-pro'], adapter) -``` - -## LlmAdapter - -适配器基类。子类必须实现 `stream()` 方法。 - -### stream(options) - -- **options:** `GenerateOptions` -- **返回值:** `AsyncIterable` - -将统一请求格式转换为具体 API 的流式调用。 - -## GenerateOptions - -```typescript -interface GenerateOptions { - model: string - messages: Message[] - tools?: ToolSpec[] - system?: string - maxTokens?: number - temperature?: number -} -``` - -| 字段 | 说明 | -|------|------| -| `model` | 请求的模型名 | -| `messages` | 对话历史 | -| `tools` | 当前可用的 tool 列表(JSON Schema 格式) | -| `system` | 系统提示词 | -| `maxTokens` | 最大输出 token | -| `temperature` | 采样温度 | - -## StreamChunk - -流式响应的增量 chunk 类型: - -```typescript -type StreamChunk = - | { type: 'block-start'; index: number; blockType: 'text' | 'tool-call' } - | { type: 'text-delta'; index: number; text: string } - | { type: 'tool-call-delta'; index: number; id: CallId; name: string; argumentsDelta: string } - | { type: 'block-end'; index: number; block: ContentBlock } - | { type: 'usage'; usage: TokenUsage } - | { type: 'finish'; reason: FinishReason } -``` - -### 协议规则 - -1. 每个内容块以 `block-start` 开始,以 `block-end` 结束 -2. `index` 从 0 递增 -3. `text-delta` 只在 `blockType: 'text'` 的块中 -4. `tool-call-delta` 只在 `blockType: 'tool-call'` 的块中 -5. `usage` 在 `finish` 之前 -6. `finish` 必须是最后一个 chunk - -## CallId - -Tool call 的 opaque branded ID: - -```typescript -import { CallId } from '@deepseek-ai/dsh-llm' - -const id = CallId('call-abc123') -``` - -## TokenUsage - -```typescript -interface TokenUsage { - inputTokens: number - outputTokens: number -} -``` - -## FinishReason - -```typescript -type FinishReason = - | { kind: 'stop' } - | { kind: 'tool-calls' } - | { kind: 'max-tokens' } -``` - -## Message - -对话消息类型: - -```typescript -interface Message { - role: 'user' | 'assistant' - content: ContentBlock[] -} -``` - -## ContentBlock - -```typescript -type ContentBlock = - | { type: 'text'; text: string } - | { type: 'tool-call'; id: CallId; name: string; arguments: string } - | { type: 'tool-result'; callId: CallId; content: ContentBlock[]; isError?: boolean } -``` diff --git a/website/zh-CN/api/harness/session.md b/website/zh-CN/api/harness/session.md deleted file mode 100644 index 5ff5b0d97b..0000000000 --- a/website/zh-CN/api/harness/session.md +++ /dev/null @@ -1,56 +0,0 @@ -# Session (dsh-session) - -会话事件流管理。 - -**包名:** `@deepseek-ai/dsh-session` -**服务名:** `ctx.session` - -## 概述 - -Session 是 Agent 的对话状态容器。所有模型可见的内容都必须经过 session 事件流记录——这是"model-visible = logged"原则的实现。 - -## SessionSurface - -会话的外部接口,用于查询当前状态。 - -### surface.messages - -- **类型:** `Message[]` - -当前会话的完整消息列表(经过 compaction 处理后的视图)。 - -### surface.events - -- **类型:** `SessionEvent[]` - -原始事件流。 - -## SessionEvent - -会话中所有变更以事件形式记录: - -```typescript -type SessionEvent = - | { type: 'user/message'; content: ContentBlock[] } - | { type: 'assistant/message'; content: ContentBlock[] } - | { type: 'tool/call'; name: string; args: unknown; callId: CallId } - | { type: 'tool/result'; callId: CallId; content: ContentBlock[]; isError?: boolean } - | { type: 'compact/start'; range: [number, number] } - | { type: 'compact/end'; summary: string } - | { type: 'todo/write'; items: TodoItem[] } - // ... 更多事件类型 -``` - -## 设计原则 - -### Model-visible = Logged - -任何到达模型请求的内容都必须能从 session log 重建。如果你要引入新的模型可见输入,必须先定义对应的 session event。 - -### 事件是 append-only - -Session 事件流是只追加的。修改历史(如 compaction)通过新事件(compact/start + compact/end)表达,而不是修改旧事件。 - -### 持久化 - -Session 事件流可以通过 `dsh-session-persistence` 持久化到磁盘(JSONL 或 SQLite),实现跨进程恢复。 diff --git a/website/zh-CN/api/harness/subagent.md b/website/zh-CN/api/harness/subagent.md deleted file mode 100644 index 97ad7b5c87..0000000000 --- a/website/zh-CN/api/harness/subagent.md +++ /dev/null @@ -1,85 +0,0 @@ -# Subagent (dsh-subagent) - -子代理委派接口。 - -**接口包:** `@deepseek-ai/dsh-subagent` -**实现:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` -**消费者:** `@deepseek-ai/dsh-tool-subagent` - -## Subagent Service - -### ctx.subagent.run(request) - -- **request:** `SubagentRequest` -- **返回值:** `Promise` - -委派一个任务给子代理执行。 - -## SubagentRequest - -```typescript -interface SubagentRequest { - /** 使用的 provider 名称 */ - provider: string - /** 委派给子代理的提示 */ - prompt: string - /** 子代理使用的模型(可选,默认继承父) */ - model?: string -} -``` - -## SubagentResult - -```typescript -interface SubagentResult { - /** 子代理的最终回复 */ - response: string -} -``` - -## Provider 模式 - -Subagent 支持多种"后端"(provider),通过配置选择: - -### spawn - -创建一个全新的子代理实例,没有父级的对话历史: - -```yaml -- name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn -``` - -### fork - -创建一个携带父级已完成 turn 前缀的子代理,子代理"知道"父级的对话上下文: - -```yaml -- name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork -``` - -## 模型可用的 Tools - -通过 `dsh-tool-subagent` 暴露。可以加载多次,每次绑定不同 provider: - -```yaml -# 暴露为 "subagent" tool,使用 spawn 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -# 暴露为 "subagent_fork" tool,使用 fork 后端 -- name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork -``` - -## 使用场景 - -- **spawn** — 独立子任务(如"搜索这个问题"),子代理不需要知道父级上下文 -- **fork** — 需要上下文的子任务(如"基于我们刚才讨论的,去实现这个"),子代理继承父级的对话前缀 diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md deleted file mode 100644 index d2011ad85e..0000000000 --- a/website/zh-CN/api/harness/tools.md +++ /dev/null @@ -1,122 +0,0 @@ -# Tools (dsh-tools) - -Tool 注册表和 `defineTool` DSL。 - -**包名:** `@deepseek-ai/dsh-tools` -**服务名:** `ctx.tools` - -## ToolRegistry - -### ctx.tools.register(tool) - -- **tool:** `ToolDefinition` -- **返回值:** `() => void` disposer - -注册一个 tool。返回的 disposer 可手动撤销注册(通常不需要,插件卸载时自动撤销)。 - -## defineTool\(options) - -类型安全的 tool 定义辅助函数。 - -```typescript -import { defineTool } from '@deepseek-ai/dsh-tools' - -const tool = defineTool({ - name: 'read_file', - description: 'Read a file from disk.', - parameters: { - path: { type: 'string', required: true, description: 'Absolute file path' }, - offset: { type: 'number' }, - limit: { type: 'number', description: 'Max lines to read' }, - }, - async execute(args) { - // args: { path: string; offset?: number; limit?: number } - }, -}) -``` - -### DefineToolOptions\ - -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | `string` | Tool 名称(全局唯一) | -| `description` | `string` | 发送给模型的描述 | -| `parameters` | `SchemaSpec` | 参数 schema(见下文) | -| `execute` | `(args: InferArgs, exec: ToolExecution) => Promise` | 执行函数 | -| `presentCall?` | `(args: InferArgs) => ToolCallView \| undefined` | UI 展示(纯函数) | -| `presentResult?` | `(args: InferArgs, result: ToolResult) => ToolResultView \| undefined` | 结果 UI 展示(纯函数) | - -## SchemaSpec - -参数 schema DSL。每个属性是一个 `SchemaProp`: - -```typescript -interface SchemaProp { - type: 'string' | 'number' | 'boolean' | 'object' | 'array' - required?: true - description?: string - enum?: string[] - properties?: SchemaSpec // type: 'object' 时 - items?: SchemaProp // type: 'array' 时 -} -``` - -### 类型推导 (InferArgs) - -`InferArgs` 自动从 `SchemaSpec` 推导 TypeScript 类型: - -- `required: true` → 必填字段 -- 无 `required` → 可选字段(`?`) -- `type: 'object'` + `properties` → 递归推导嵌套对象 -- `type: 'array'` + `items` → 推导为数组 - -## ToolDefinition - -运行时 tool 定义(`defineTool` 的返回值): - -```typescript -interface ToolDefinition { - name: string - description: string - parameters: Record // JSON Schema - execute(args: unknown, exec: ToolExecution): Promise - presentCall?(args: unknown): ToolCallView | undefined - presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined -} -``` - -## ToolExecuteReturn - -```typescript -type ToolExecuteReturn = - | ContentBlock[] // 仅内容 - | { content: ContentBlock[]; meta?: unknown } // 内容 + 元信息 -``` - -## ToolArgsError - -当模型生成的参数不匹配 schema 时抛出: - -```typescript -class ToolArgsError extends HarnessError { - code: 'INVALID_ARGS' - violations: string[] -} -``` - -框架自动捕获并转换为 `isError` 结果返回给模型。 - -## validateArgs(spec, args) - -- **spec:** `SchemaSpec` -- **args:** `unknown` -- **返回值:** `string[]` 违规信息列表(空 = 合法) - -手动校验参数。`defineTool` 内部使用,通常不需要直接调用。 - -## schemaSpecToJsonSchema(spec) - -- **spec:** `SchemaSpec` -- **返回值:** `JsonSchemaObject` - -将 SchemaSpec 转换为标准 JSON Schema。用于发送给模型的 wire format。 diff --git a/website/zh-CN/api/index.md b/website/zh-CN/api/index.md deleted file mode 100644 index 371cd1e622..0000000000 --- a/website/zh-CN/api/index.md +++ /dev/null @@ -1,25 +0,0 @@ -# API 参考 - -本节提供 DeepSeek Harness 的完整 API 参考文档,分为两部分: - -## 框架 API - -Cordis 微内核提供的基础能力,所有插件开发都建立在这些 API 之上: - -- [Context](./cordis/context) — 上下文对象,所有服务和方法的入口 -- [Events](./cordis/events) — 事件系统 API(emit / on / bail / serial / waterfall) -- [Fiber](./cordis/fiber) — 作用域生命周期(状态机、effect、dispose) -- [Registry](./cordis/registry) — 插件注册(plugin / inject) -- [Service](./cordis/service) — 服务基类 - -## Harness API - -DeepSeek Harness SDK 提供的扩展 API,用于构建 Agent 能力: - -- [Tools (dsh-tools)](./harness/tools) — Tool 注册、defineTool DSL、Schema 类型系统 -- [LLM (dsh-llm)](./harness/llm) — LLM 服务、适配器注册、StreamChunk 协议 -- [Session (dsh-session)](./harness/session) — 会话事件流、消息类型 -- [Agent (dsh-agent)](./harness/agent) — Agent 实例管理、生命周期 -- [Bash (dsh-bash)](./harness/bash) — Bash 执行接口 -- [Filesystem (dsh-fs)](./harness/fs) — 文件系统接口 -- [Subagent (dsh-subagent)](./harness/subagent) — 子代理委派接口 diff --git a/website/zh-CN/design/composability.md b/website/zh-CN/design/composability.md deleted file mode 100644 index 8370d8e136..0000000000 --- a/website/zh-CN/design/composability.md +++ /dev/null @@ -1,72 +0,0 @@ -# 可组合性与插件系统 - -## 组合 - -编程的本质就是组合。将小的构建块拼装为更大的系统,再将大系统作为块继续拼装——这是从函数到模块到微服务一脉相承的思想。 - -组合可以分为两种: - -- **静态组合**:编译期确定的组合,例如函数调用、模块导入。 -- **动态组合**:运行时确定的组合,例如热更新、插件加载/卸载。 - -静态组合是逻辑的组合;动态组合为可组合性引入了时间和空间两个新维度。 - -## 三种可组合性 - -| 维度 | 定义 | 对应问题 | -|------|------|----------| -| **逻辑可组合性** (Logical) | 功能能否被任意拆分和组装 | 接口设计是否正交 | -| **时间可组合性** (Temporal) | 能否灵活、安全地控制组合的运行时序 | 能否热加载/卸载而不泄漏 | -| **空间可组合性** (Spatial) | 能否灵活、安全地管理组合的依赖关系 | 依赖缺失时行为是否确定 | - -一门编程语言或应用框架越多地使用组合范式,就称它的可组合性越好。 - -## 传统插件系统的问题 - -插件系统是动态组合的典型形式。浏览器扩展、IDE 插件、操作系统驱动,都是其实例。然而大多数插件系统并不可靠。 - -### 不可逆的插件化 - -以 VSCode 为例: - -- 卸载或更新插件时需要重启整个系统。 -- 无法在运行时追踪和回收副作用,导致内存泄漏和非预期的资源占用。 -- 即便提供了 `deactivate` 钩子,也无法强制开发者正确实现清理逻辑。 - -**根本原因**:未做到时间可组合——系统不知道某个插件产生了哪些副作用、占用了哪些资源。 - -### 不完全的插件化 - -- 无法表达插件间的依赖关系,扩展能力受限。 -- 只有外围功能被下放给插件,核心功能依然通过修改主体代码来实现。 - -**根本原因**:未做到空间可组合——系统缺乏对依赖关系的建模和管理。 - -## Cordis 的解法 - -Cordis 同时解决了上述两个问题: - -1. **可逆作用** (Revertible Effects) 实现时间可组合性——所有注册自动追踪、自动回收。 -2. **响应式余作用** (Reactive Coeffects) 实现空间可组合性——依赖声明驱动加载顺序。 - -两者通过**上下文模型** (Context Model) 统一为单一的编程范式:开发者只需通过 `ctx` 调用框架 API,可逆性和依赖管理由框架保证。 - -## 在 Harness 中的体现 - -DeepSeek Harness 将 Cordis 的可组合性应用到 Agent 开发领域: - -```typescript -// 一个 Harness 插件天然是可逆的 -export const inject = ['tools', 'llm'] // 空间可组合:声明依赖 - -export function apply(ctx: Context) { - // 时间可组合:注册会被自动追踪和回收 - ctx.tools.register(defineTool('my-tool', { - description: '...', - parameters: { /* ... */ }, - async execute(args) { /* ... */ }, - })) -} -``` - -插件卸载时,tool 自动注销、事件监听自动移除——无需手动清理。依赖的服务(如 `llm`)消失时,插件自动挂起;恢复时自动重新加载。 diff --git a/website/zh-CN/design/context-model.md b/website/zh-CN/design/context-model.md deleted file mode 100644 index cc25df88e5..0000000000 --- a/website/zh-CN/design/context-model.md +++ /dev/null @@ -1,129 +0,0 @@ -# 上下文模型 - -上下文 (Context) 是 Cordis 将作用与余作用统一的运行时模型。它提供了一种编程范式,允许开发者无心智负担地编写时间、空间可组合的程序。 - -## 作用上下文 (Effect Context) - -当副作用被记录到全局环境时,$\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)$ 也就变成了一个更大的 $\mathcal{C}$。 - -递归地定义: - -$$ -\begin{matrix} -\mathcal{C}_1=\mathcal{C}_0\times\left(\mathcal{C}_0\to\mathcal{C}_0\right)\\ -\mathcal{C}_2=\mathcal{C}_1\times\left(\mathcal{C}_1\to\mathcal{C}_1\right)\\ -\cdots\\ -\mathcal{C}_{n+1}=\mathcal{C}_n\times\left(\mathcal{C}_n\to\mathcal{C}_n\right)\\ -\end{matrix} -$$ - -每一层 $\mathcal{C}$ 包含上一层的状态,同时记录了上一层的副作用。 - -利用递归类型得到真正的作用上下文: - -$$ -\mathcal{C}=\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right) -$$ - -这就是 Cordis Context 的理论根基:**上下文既是状态容器,又是副作用追踪器。** - -## 上下文的派生 - -当一个插件被加载时,从当前上下文派生出新的上下文实例: - -``` -Root Context -├── Plugin A Context ← 管理 A 的副作用 -│ └── Sub-plugin Context -└── Plugin B Context ← 管理 B 的副作用 -``` - -- 子级上下文管理插件内部的全部副作用 -- 插件整体作为一个副作用被父级上下文收集 -- 父级 dispose 时,子级先被 dispose(保证依赖逆序) - -## 余作用上下文 (Coeffect Context) - -余作用由作用产生: - -- **提供服务**本身是一种作用——它占用了服务命名空间资源 -- 因此服务的提供被记录在作用上下文中 -- 上下文将作用与余作用关联起来,提供了统一的时间、空间可组合性 - -```typescript -// 提供服务 = 一个 effect(占用 ctx.llm 这个 "资源") -class LlmService extends Service { - // 当此插件卸载时,ctx.llm 被回收(effect 的逆操作) - // 所有依赖 llm 的插件因 coeffect 不满足而挂起 -} -``` - -## 基于上下文的开发范式 - -上下文模型提供了两个关键优势: - -### 无感性 (Transparent) - -框架将领域中的所有方法都封装为 effect 版本。开发者只需调用 `ctx` 上的方法,就能自动获得时间/空间可组合性: - -```typescript -export function apply(ctx: Context) { - // 以下每一行都是 effect——卸载时自动逆序回收 - ctx.on('agent/step-result', validateResult) - ctx.tools.register(myTool) - ctx.llm.registerAdapter(['my-model'], adapter) - - // 开发者无需知道"可逆作用"的存在 - // 只需通过 ctx 调用,框架保证一切安全 -} -``` - -### 渐进性 (Incremental) - -可以逐步将现有框架中的 API 替换为可组合版本,无需一次性重写: - -```typescript -// 第一步:用 ctx.effect 包装遗留 API -ctx.effect(() => { - const legacy = legacySystem.register(handler) - return () => legacySystem.unregister(legacy) -}) - -// 第二步:在未来将遗留 API 原生改造为 effect -// 两种方式可以并存 -``` - -## 在 Harness 中的完整图景 - -DeepSeek Harness 的运行时是一个 Context 树: - -``` -Root Context (Cordis 应用) -├── dsh-session (提供 ctx.sessions) -├── dsh-tools (提供 ctx.tools) -├── dsh-llm (提供 ctx.llm) -│ └── deepseek-adapter (注册模型适配器) -├── dsh-agent-loop (提供 ctx.agentLoop) -├── dsh-bash (提供 ctx.bash) -│ └── bash-local (本地执行器实现) -├── dsh-fs (提供 ctx.fs) -│ └── fs-local (本地 FS 实现) -├── dsh-system-prompt (提供 ctx.systemPrompt) -└── Agent Context (由 agents.create() 派生) - ├── Agent 自己注册的 tools - ├── Agent 的 session - └── Subagent Context (进一步派生) -``` - -每个节点都是一个 Context 实例。插件加载/卸载、服务出现/消失、Agent 创建/销毁——这一切都在 Context 树上以统一的语义发生。 - -## 总结 - -| 概念 | 解决的问题 | Cordis 机制 | -|------|-----------|-------------| -| 作用上下文 | 副作用追踪与回收 | `ctx.effect()` / `fiber.dispose()` | -| 上下文派生 | 副作用的层级隔离 | `ctx.plugin()` 创建子 Context | -| 余作用上下文 | 依赖的动态管理 | `inject` 声明 + 服务生命周期 | -| 统一范式 | 开发者无需关心底层机制 | 只需通过 `ctx` 调用 API | - -这就是为什么 Harness 能在保持「一切皆插件」的同时,不给插件开发者增加心智负担——**上下文模型把复杂性封装在了框架内部**。 diff --git a/website/zh-CN/design/effects-coeffects.md b/website/zh-CN/design/effects-coeffects.md deleted file mode 100644 index 01c181315f..0000000000 --- a/website/zh-CN/design/effects-coeffects.md +++ /dev/null @@ -1,69 +0,0 @@ -# 作用与余作用 - -## 作用 (Effects) - -Effects 是程序中对系统状态或外部环境产生影响的操作:I/O、状态修改、资源占用等。 - -学术界对作用有两种主要建模方式: - -### 单子作用 (Monadic Effects) - -- 通过单子 (monad) 将副作用封装为类型安全的计算链。 -- 提供 `return`(纯值注入)和 `bind`(链式组合)两个基本操作。 -- 以纯函数式的方式处理带有副作用的计算。(Moggi 1991, Wadler 1992) -- 代表语言:Haskell (IO Monad)、Rust (Result/Option) - -### 代数作用 (Algebraic Effects) - -- 允许在函数中"抛出"一个 effect,在调用栈的更高层次"捕获"并处理。 -- 类似异常处理,但更通用——处理后可以恢复执行。 -- 代表语言:Koka、Eff、OCaml 5+ (Kiselyov 2018, Kawahara 2020) - -## 余作用 (Coeffects) - -Coeffects 是程序执行时依赖的上下文信息:环境变量、系统资源、外部服务等。 - -- Coeffects 是 effects 的对偶 (dual) 概念,通常通过余单子 (comonad) 建模。(Petricek 2013, 2014; Brünnler 2014) -- 更前沿的理论将带有资源的上下文建模为 **graded algebra**(有序半环加最大元): - - 加法 = 并行组合;0 元 = 无资源 - - 乘法 = 串行组合;1 元 = 单位资源 - - 序 = 资源约束;最大元 = 无限资源 - - (Breuvart 2015, Gaboardi 2016, Dal Lago 2022) - -## 现有理论的不足 - -这些理论主要面向**静态分析**和**短时程序**: - -1. **缺乏运行时追踪**:类型系统能标记副作用的存在,但无法在运行时追踪和回收。对长时运行程序(服务端、Agent),这意味着资源泄漏不可避免。 - -2. **缺乏动态性**:面向编译期分析,无法处理运行时的加载/卸载需求。 - -3. **崩溃而非降级**:类型不满足时直接拒绝编译或运行时崩溃,而长时运行程序更希望安全降级——挂起不满足依赖的部分,而非停止整个系统。 - -## Cordis 的突破 - -Cordis 选择了不同的路径——在运行时层面解决可组合性问题: - -| 现有理论 | Cordis 方案 | -|----------|-------------| -| 类型标记副作用 | 运行时追踪并自动回收副作用 | -| 编译期拒绝 | 运行时挂起/恢复 | -| 面向短时程序 | 面向长时运行程序设计 | - -这由两个互补机制实现: - -- **[可逆作用](./revertible-effects)** — 将副作用形式化为可逆的群操作 -- **[响应式余作用](./reactive-coeffects)** — 将依赖建模为具有生命周期的服务 - -## 在 Agent 开发中的意义 - -对 DeepSeek Harness 而言,作用/余作用模型直接支撑了以下能力: - -| 作用 (Effect) | 余作用 (Coeffect) | -|---------------|-------------------| -| 注册一个 tool | 依赖 tool registry 服务 | -| 注册一个 LLM adapter | 依赖 LLM 服务接口 | -| 监听 session 事件 | 依赖 session 服务存在 | -| 启动子进程 | 依赖 bash executor 实现 | - -每一个 effect 都可逆(tool 可注销、adapter 可移除);每一个 coeffect 都有生命周期(服务消失则依赖者挂起)。这就是 Agent 能被安全热替换的根本原因。 diff --git a/website/zh-CN/design/index.md b/website/zh-CN/design/index.md deleted file mode 100644 index de6ebcf7aa..0000000000 --- a/website/zh-CN/design/index.md +++ /dev/null @@ -1,39 +0,0 @@ -# 系统设计 - -DeepSeek Harness 建立在 Cordis 微内核之上,采用「一切皆插件」的架构。本节阐述这套设计背后的理论基础和设计哲学。 - -## 核心思想 - -Harness 追求三种可组合性的统一: - -| 维度 | 含义 | Cordis 对应机制 | -|------|------|----------------| -| 逻辑可组合性 | 功能能否自由拆分和拼装 | 插件系统、事件系统 | -| 时间可组合性 | 运行时能否安全地加载/卸载功能 | 可逆作用、自动清理 | -| 空间可组合性 | 依赖关系能否被安全地声明和管理 | 服务生命周期、依赖注入 | - -这三种可组合性在上下文模型中统一为单一的编程范式。 - -## 目录 - -- [可组合性与插件系统](./composability) — 组合的本质,以及传统插件系统为什么不可靠 -- [作用与余作用](./effects-coeffects) — Cordis 效果系统的理论模型 -- [可逆作用](./revertible-effects) — 时间可组合性的形式化定义与证明 -- [响应式余作用](./reactive-coeffects) — 空间可组合性的服务语义 -- [上下文模型](./context-model) — Context 如何将作用与余作用统一 - -## 设计如何映射到 Harness - -| 理论概念 | Harness 中的体现 | -|----------|-----------------| -| 可逆作用 | `ctx.tools.register()` 返回 disposer;插件卸载时工具自动注销 | -| 响应式余作用 | `inject: ['llm']` 声明依赖;LLM 适配器不可用时插件自动挂起 | -| 上下文派生 | 子 Agent 拥有独立 Context,继承父级服务但有独立生命周期 | -| Waterfall 事件 | `agent/request` 链式拦截,任一监听器可决定最终请求参数 | -| Capability seam | bash/fs/web 三层拆分:接口 → 实现 → 模型工具 | - -## 进一步阅读 - -- [插件与生命周期](/zh-CN/develop/framework/) — 实践中的 Fiber 状态机 -- [服务与依赖](/zh-CN/develop/framework/service) — 服务声明与注入 -- [能力的三层拆分](/zh-CN/develop/practice/) — Capability seam 模式 diff --git a/website/zh-CN/design/reactive-coeffects.md b/website/zh-CN/design/reactive-coeffects.md deleted file mode 100644 index 45345f934a..0000000000 --- a/website/zh-CN/design/reactive-coeffects.md +++ /dev/null @@ -1,90 +0,0 @@ -# 响应式余作用 - -响应式余作用 (Reactive Coeffects) 是 Cordis 实现**空间可组合性**的核心机制。 - -- 将代码中的资源依赖抽象为服务 (service) 的概念 -- 通过运行时生命周期语义,实现自动、安全、高效的资源管理 - -## 依赖的本质是生命周期 - -传统的依赖注入(如 Angular DI、Spring IoC)解决的是"怎么拿到依赖"的问题,但忽略了一个关键问题:**依赖是有生命周期的**。 - -一个数据库连接池可能重启,一个 API 服务可能下线,一个 LLM adapter 可能被热替换。当依赖消失时,依赖者应当如何表现? - -- 崩溃?——对长时运行程序不可接受。 -- 继续运行?——可能产生不一致状态。 -- **自动挂起,等待恢复?**——Cordis 的选择。 - -## 服务与生命周期 - -Cordis 将程序中的资源依赖抽象为**服务** (service): - -- 任何插件都可以声明自己依赖的服务列表 -- 服务存在明确的生命周期(提供、撤销) -- 运行时对依赖不满足的插件**等待**,而非拒绝 -- 服务生命周期结束前,依赖该服务的插件**先一步被回收** - -```typescript -// LLM 适配器插件:提供 llm 服务 -export class LlmService extends Service { - static inject = ['http'] // 自身依赖 http - // 当 http 不可用时,LlmService 自动挂起 - // 挂起导致 ctx.llm 不可用 - // 所有 inject: ['llm'] 的插件级联挂起 -} -``` - -## 与现有理论的对比 - -### 与 Comonad 余作用比较 - -基于 Comonad 的余作用(Petricek 2013)将上下文建模为静态结构,侧重于编译期分析。Cordis 的响应式余作用额外引入了**时序语义**: - -- 服务可在运行时出现/消失 -- 依赖关系随之动态建立/解除 -- 效果的生命周期由依赖关系决定 - -### 与 Grade Algebra 余作用比较 - -基于 Grade Algebra 的余作用(Gaboardi 2016)用有序半环描述资源的组合规则。Cordis 的服务依赖可以建模为**交换半群**: - -- 服务名构成依赖集合 -- 集合并(∪)对应并行依赖 -- 交换律:依赖 A + B ≡ 依赖 B + A(声明顺序无关) -- 结合律:依赖分组方式不影响语义 - -但 Cordis 还增加了代数不具备的运行时行为:当集合中的某个服务不可用时,整个依赖集不满足,触发挂起。 - -## 在 Cordis 中的实现 - -```typescript -// 声明依赖 -export const inject = ['tools', 'llm'] - -export function apply(ctx: Context) { - // 到这里时,ctx.tools 和 ctx.llm 一定可用 - // 如果任一服务消失,此插件自动卸载 - // 服务恢复后,自动重新执行 apply -} -``` - -服务生命周期变化时的行为: - -``` -llm service 可用 → 依赖 llm 的插件 PENDING → ACTIVE -llm service 消失 → 依赖 llm 的插件 ACTIVE → DISPOSED -llm service 恢复 → 依赖 llm 的插件重新 PENDING → ACTIVE -``` - -## 为什么 Agent 需要响应式余作用 - -在 Harness 场景下,响应式余作用直接支撑: - -| 场景 | 行为 | -|------|------| -| LLM adapter 热替换 | 依赖 `llm` 的插件自动挂起/恢复,中间不丢状态 | -| 按需加载 bash 执行器 | bash tool 只在 `bash` 服务就绪后注册 | -| 子 Agent 独立服务空间 | 通过 `ctx.isolate()` 隔离服务实例,互不干扰 | -| 可选能力降级 | `inject: { web: { required: false } }` 允许 web 不可用时继续运行 | - -这意味着 Harness 插件开发者无需编写防御性的 "if service exists" 检查——框架保证:当你的 `apply` 被调用时,声明的依赖一定已就绪。 diff --git a/website/zh-CN/design/revertible-effects.md b/website/zh-CN/design/revertible-effects.md deleted file mode 100644 index 5133400e75..0000000000 --- a/website/zh-CN/design/revertible-effects.md +++ /dev/null @@ -1,128 +0,0 @@ -# 可逆作用 - -可逆作用 (Revertible Effects) 是 Cordis 实现**时间可组合性**的核心机制。 - -- 在单子作用的基础上增加可逆性约束 -- 提供面向长时运行程序的作用系统 -- 确保程序可以在插件粒度上回到任意状态 - -## 副作用的封装 - -现实中的程序需要与各种副作用打交道。假设一个不纯函数: - -$$ -f_\text{impure}: \text{X}\to\text{Y} -$$ - -我们将所有可能的副作用用类型 $\mathcal{C}$ 封装,函数变为: - -$$ -f: \mathcal{C}\times\text{X}\to\mathcal{C}\times\text{Y} -$$ - -对于长时运行程序,忽略函数本身的入参和出参,$f$ 属于函数空间 $\mathfrak{F}=\mathcal{C}\to\mathcal{C}$。 - -## 从幺半群到群 - -任何函数 $f: \mathcal{C}\to\mathcal{C}$ 都是状态空间到自身的变换。在组合 $\circ$ 下构成**幺半群**: - -1. 封闭性:$f\circ g$ 也是 $\mathcal{C}\to\mathcal{C}$ -2. 结合律:$(f\circ g)\circ h=f\circ (g\circ h)$ -3. 单位元:$\text{id}$,使得 $f\circ\text{id}=\text{id}\circ f=f$ - -如果额外要求每个 $f$ 存在逆元 $f^{-1}$(即副作用可回收),$\mathfrak{F}$ 升级为**群**。 - -## 副作用都可逆吗? - -观察计算机中的副作用模式: - -| 操作 | 占用资源 | 逆操作 | -|------|----------|--------| -| 打开文件 | 文件描述符 | 关闭文件 | -| 创建子进程 | 进程号 | 杀死进程 | -| 监听端口 | 端口 | 取消监听 | -| 添加回调函数 | 事件槽位 | 删除回调 | -| 分配内存 | 内存区块 | 回收内存 | - -**副作用就是对资源的占用。** 计算机的资源天然设计为可重复使用,因此这些副作用一定是可逆的。 - -## 追踪和回收副作用 - -Cordis 通过 $\text{effect}$ 和 $\text{restore}$ 函子追踪和回收逆函数。 - -### effect 函子 - -$$ -\begin{array}{} -\text{effect}&:& -\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ -\text{effect}&=&f&\mapsto&\left(c, h\right)&\mapsto&\left(f(c), h\circ f^{-1}\right) -\end{array} -$$ - -直觉:执行 $f$ 产生的副作用记入状态 $c$,同时将逆操作 $f^{-1}$ 追加到回收链 $h$ 中。 - -### 同态性证明 - -$\text{effect}$ 是从 $\mathcal{C}\to\mathcal{C}$ 到 $\mathcal{C}\times(\mathcal{C}\to\mathcal{C})\to\mathcal{C}\times(\mathcal{C}\to\mathcal{C})$ 的同态: - -$$ -\begin{aligned} -\text{effect}\ (f\circ g) \left(c, h\right) -&=\left((f\circ g)(c), h\circ (f\circ g)^{-1}\right)\\ -&=\left(f(g(c)), h\circ g^{-1}\circ f^{-1}\right)\\ -&=\left(\text{effect}\ f\right)\left(g(c), h\circ g^{-1}\right)\\ -&=\left(\text{effect}\ f\right)\circ\left(\text{effect}\ g\right) \left(c, h\right) -\end{aligned} -$$ - -这意味着:组合两个操作后再追踪 = 分别追踪后再组合。副作用追踪与执行顺序无关。 - -### restore 函子 - -$$ -\begin{array}{} -\text{restore}&:& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to& -\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\ -\text{restore}&=&\left(c, h\right)&\mapsto&\left(h(c),\text{id}\right) -\end{array} -$$ - -直觉:将回收链 $h$ 应用到当前状态,一次性回收所有已追踪的副作用。 - -## 在 Cordis 中的实现 - -理论映射到 API: - -| 数学概念 | Cordis API | 说明 | -|----------|-----------|------| -| $\text{effect}(f)$ | `ctx.effect(() => { ...; return dispose })` | 注册副作用并返回清理函数 | -| $\text{restore}$ | `fiber.dispose()` | 执行 Fiber 的整个回收链 | -| $f^{-1}$ | dispose 返回值 / cleanup 函数 | 逆操作 | - -```typescript -export function apply(ctx: Context) { - // effect: 创建资源,返回其逆操作 - ctx.effect(() => { - const server = startServer(8080) // f: 占用端口 - return () => server.close() // f⁻¹: 释放端口 - }) - - // 框架 API 内部已封装 effect - ctx.on('event', handler) // 内部: effect(addListener, removeListener) - ctx.tools.register(myTool) // 内部: effect(addTool, removeTool) -} -// 当此插件被卸载时,restore 自动按逆序执行所有 f⁻¹ -``` - -## 为什么 Agent 需要可逆作用 - -在 Harness 场景下,可逆作用直接支撑: - -- **热替换 LLM 适配器**:卸载旧适配器(回收注册)、加载新适配器,无需重启 -- **动态 tool 管理**:根据对话上下文动态添加/移除 tool,不泄漏 -- **子 Agent 生命周期**:子 Agent 完成后,其注册的所有临时 tool 和监听器自动清理 -- **优雅关闭**:进程退出时所有插件按依赖逆序 dispose,确保资源完全释放 diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md deleted file mode 100644 index d555a0a478..0000000000 --- a/website/zh-CN/guide/config.md +++ /dev/null @@ -1,342 +0,0 @@ -# 配置文件 - -Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参数运行。 - -## 从例子开始 - -### echo-agent 的配置 - -这是一开始的第一个 Agent 的完整配置: - -```yaml -# 热替换:修改代码后自动重载,不用手动重启 -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# Mock 模型:从本地 `.ts` 文件加载,注册一个名为 `mock-llm` 的工具 -# 本地模拟 LLM 响应,不联网 -- id: mock-llm - name: './src/mock-llm.ts' - -# Echo 工具:收到文本后转大写返回 -- id: echo-tool - name: './src/echo-tool.ts' - -# Bash 执行器:从 npm 包 `@deepseek-ai/dsh-bash-local`加载,提供 bash 命令执行能力 -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -# 应用主体:把 session 管理、tool 调度、agent loop 等组装成一个可交互的终端 Agent -# 只需告诉它用哪个模型 (`model`)、什么人设 (`persona`) -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: mock-echo - persona: 'You are echo-agent, a demo agent.' - welcome: 'echo-agent ready. Type a message ("echo " triggers the tool).' - persistenceRoot: './.sessions' -``` - -### coding-agent 的配置 - -真实场景——接入 DeepSeek API,带完整工具链: - -```yaml -# 热替换:同上,开发时自动重载 -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 -# `!!js` 从环境变量读取密钥,不会写进配置文件 -# `models` 声明该适配器能处理哪些模型名 -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - models: - - deepseek-v4-pro - - deepseek-v4-flash - -# Bash 执行器:让 Agent 能跑 shell 命令 -# timeoutMs 设置单条命令的超时时间 -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# 应用主体:和 echo-agent 一样的框架,只是配置不同 -# `model` 指定默认使用哪个模型(要和上面 models 列表里的名字对应) -# `persona` 是系统提示词,{{model}} 会被替换为实际模型名 -# `resumeSessionId` 设了就恢复旧对话,没设就每次新建 -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: deepseek-v4-flash - resumeSessionId: !!js process.env.RESUME_SESSION_ID - persistenceRoot: './.sessions' - welcome: 'agent REPL ready. Give it a coding task.' - persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. - Verify your work by running the code or tests. Keep answers brief and factual. - -# 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 -# contextWindow 是模型能看到的 token 上限 -# thresholdRatio 超过这个比例就触发压缩 -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - config: - contextWindow: 128000 - thresholdRatio: 0.8 - retainTokens: 20480 - maxTokens: 8192 - -# 子代理:把子任务分配给独立的 Agent 去做 -# subagent 是服务注册,spawn/fork 是两种委派方式: -# spawn — 全新子代理,不知道父级在聊什么 -# fork — 继承父级对话上下文的子代理 -# tool-subagent 把委派能力暴露给模型,toolName 是模型看到的工具名 -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -# 任务追踪:模型可以用 todo_write 记录和更新任务清单 -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# 文件系统:让 Agent 能读写编辑文件 -# fs-local 提供本地文件操作能力,cwd 是工作目录 -# fs-policy 是安全策略——必须先读才能写,防止模型盲写 -# tool-fs 把能力暴露给模型(read / write / edit 三个工具) -- 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' -``` - -和 echo-agent 对比:同一个 `dsh-stdio-agent` 应用主体,只是把 mock 换成了真实 API,加上了更多工具插件。 - -## 语法详解 - -### 插件声明字段 - -每个插件条目支持以下字段: - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `name` | string | 是 | 插件来源(npm 包名或相对路径) | -| `id` | string | 否 | 实例标识符,用于日志和调试 | -| `config` | object | 否 | 传递给插件的配置 | -| `disabled` | boolean | 否 | 设为 `true` 临时禁用该插件 | - -### 插件来源 (`name`) - -**npm 包** — 已安装的 `@deepseek-ai/dsh-*` 包或第三方包: - -```yaml -- name: '@deepseek-ai/dsh-llm-deepseek' -``` - -**相对路径** — 本地 TypeScript 文件(相对于 `cordis.yml` 所在目录): - -```yaml -- name: './src/my-tool.ts' -``` - -### 环境变量 (`!!js`) - -用 `!!js` 标签在配置中引用运行时表达式: - -```yaml -config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - cwd: !!js process.cwd() -``` - -::: warning -是 `!!js`(两个感叹号),不是 `!js`。写错了会静默失败。 -::: - -环境变量从仓库根目录的 `.env` 文件自动加载(已被 gitignore)。 - -### 禁用插件 - -不想删配置但暂时不加载?加一行 `disabled`: - -```yaml -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - disabled: true - config: - contextWindow: 128000 -``` - -## 各插件配置参考 - -### stdio-agent(标准应用主体) - -**包名:** `@deepseek-ai/dsh-stdio-agent` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `model` | string | **必填** | 使用的模型名,需与 LLM 适配器注册的名字一致 | -| `persona` | string | `''` | 系统提示词。支持 `{{model}}` 等模板变量 | -| `toolOrder` | string[] | — | 模型看到的工具顺序。省略则按字母排序 | -| `persistenceRoot` | string | `'./.sessions'` | 会话日志存储目录 | -| `welcome` | string | `'ready.'` | 启动时显示的欢迎信息 | -| `resumeSessionId` | string | — | 恢复指定会话 ID。留空则每次新建 | - -### llm-deepseek(DeepSeek 适配器) - -**包名:** `@deepseek-ai/dsh-llm-deepseek` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | -| `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | -| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | -| `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | -| `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | - -### bash-local(Bash 执行器) - -**包名:** `@deepseek-ai/dsh-bash-local` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `cwd` | string | `process.cwd()` | 命令执行的工作目录 | -| `timeoutMs` | number | `120000` | 单条命令的超时时间(毫秒) | -| `maxTimeoutMs` | number | `600000` | 单条命令超时的上限(模型不能请求更久) | -| `maxOutputBytes` | number | `64000` | 单次输出的内存上限(超出后溢出到临时文件) | -| `graceMs` | number | `3000` | kill 时从 SIGTERM 到 SIGKILL 的等待时间 | - -### compact-basic(自动压缩) - -**包名:** `@deepseek-ai/dsh-compact-basic` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | -| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | -| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | -| `maxTokens` | number | **必填** | 总结时的最大输出 token | -| `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | -| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | -| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | -| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | - -### fs-local(文件系统) - -**包名:** `@deepseek-ai/dsh-fs-local` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `cwd` | string | `process.cwd()` | 工作目录,相对路径以此为基准 | - -### fs-policy(文件系统策略) - -**包名:** `@deepseek-ai/dsh-fs-policy` - -无配置项。加载即启用"必须先读才能写"的安全策略。 - -### tool-fs(文件系统工具) - -**包名:** `@deepseek-ai/dsh-tool-fs` - -无配置项。加载后向模型暴露 `read`、`write`、`edit` 三个工具。 - -### tool-web(Web 工具) - -**包名:** `@deepseek-ai/dsh-tool-web` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `search` | boolean | `true` | 是否注册 `web_search` 工具 | -| `fetch` | boolean | `true` | 是否注册 `web_fetch` 工具 | -| `searchMaxResults` | number | `8` | 单次搜索返回的最大结果数 | - -### subagent-spawn / subagent-fork(子代理后端) - -**包名:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `providerName` | string | `'spawn'` / `'fork'` | 注册到子代理服务的 provider 名称 | - -### tool-subagent(子代理工具) - -**包名:** `@deepseek-ai/dsh-tool-subagent` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `provider` | string | **必填** | 使用哪个 provider(如 `spawn`、`fork`) | -| `toolName` | string | `'subagent'` | 暴露给模型的工具名。多次加载时必须不同 | -| `agentOptions.model` | string | — | 子代理使用的模型名(省略则继承父代理) | - -### tool-todo(任务清单) - -**包名:** `@deepseek-ai/dsh-tool-todo` - -无配置项。加载后向模型暴露 `todo_write` 工具。 - -### hmr(热替换) - -**包名:** `@cordisjs/plugin-hmr` - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `root` | string[] | **必填** | 监听文件变更的目录列表 | - -::: tip -hmr 仅用于开发环境。它需要 `node --expose-internals` 启动参数,`demo:*` 脚本已自动添加。 -::: - ---- - -## 加载顺序 - -`cordis.yml` 的顺序就是加载顺序。推荐: - -1. **hmr** — 热替换(仅开发时需要) -2. **LLM 适配器** — 模型后端 -3. **执行器** — bash、fs 等能力提供者 -4. **应用主体** — `dsh-stdio-agent` 或 `dsh-acp-agent` -5. **附加插件** — compact、subagent、todo 等 - -应用主体内部已经捆绑了核心能力(session、tools、agent-loop),不需要手动加载。 - -## 下一步 - -- [开发插件](../develop/basic/) — 编写自己的插件 -- [API 参考](../api/) — 查看各插件完整接口 From 341b56ebc3b2fcd0d01d519bb0aa9c25847441e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 15:51:35 +0800 Subject: [PATCH 02/28] docs(skill): add documentation site sync workflow --- .agents/skills/dsh-doc-site-sync/SKILL.md | 80 +++++++++++++++++++ .../dsh-doc-site-sync/agents/openai.yaml | 4 + 2 files changed, 84 insertions(+) create mode 100644 .agents/skills/dsh-doc-site-sync/SKILL.md create mode 100644 .agents/skills/dsh-doc-site-sync/agents/openai.yaml diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md new file mode 100644 index 0000000000..0fdd9b9398 --- /dev/null +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -0,0 +1,80 @@ +--- +name: dsh-doc-site-sync +description: Use when publishing, updating, moving, or removing DeepSeek Harness documentation website pages; editing website/docs.ts mappings or navigation; diagnosing a page missing from the VitePress site; fixing projected documentation links; or running the docs:dev, docs:check, and doc-sync workflow after website-content changes. +--- + +# Synchronizing the DeepSeek Harness Documentation Site + +Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. + +## Read the owning contracts + +- Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. +- Use [dsh-translate-docs](../dsh-translate-docs/SKILL.md) whenever an edited source has a bilingual counterpart. +- Read the current `DocsPage` type and entries in [website/docs.ts](../../../website/docs.ts) before changing the manifest; do not rely on a remembered field set. +- Read [website/.vitepress/config.ts](../../../website/.vitepress/config.ts) before adding a new section, sidebar collection, locale, or top-level navigation item. + +## Classify the change + +- **Edit an already published page:** change only its canonical Markdown source. Do not touch the manifest unless its route or navigation metadata changes. +- **Publish a new page:** create it in its owning `docs/` tier, then add one manifest entry. +- **Rename, move, or remove a page:** update the canonical file, manifest entry, and inbound repository links atomically. Remove stale manifest entries; `docs:check` rejects missing sources. +- **Publish a generated catalog:** map the generated `docs/` file, but change its generator or source metadata rather than editing the catalog by hand. +- **Change site structure:** update the manifest for ordinary pages; update VitePress configuration only when the existing sidebar, section, or locale model cannot express the change. + +Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist/`. Never copy a maintained `docs/` page into `website/`. + +## Add or update a manifest entry + +Set every `DocsPage` field deliberately: + +- `source`: repository-relative canonical Markdown path. +- `route`: public VitePress path including the `.md` suffix. +- `label`: sidebar label, not necessarily the document H1. +- `sidebar`: reuse `zh-guide`, `zh-develop`, or `en-docs` unless the information architecture genuinely needs another collection. +- `section`: reuse an existing section when possible. If adding one, also place it in `sectionOrder` in the VitePress config. +- `order`: stable order within the section. +- `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. + +Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. + +## Preserve link behavior + +Write normal repository-relative Markdown links in canonical docs. The projector applies these rules: + +- A target present in the manifest becomes a site-relative route. +- An existing target outside the manifest becomes a GitHub source link, including supported line suffixes. +- External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged. +- A missing repository-relative target fails projection instead of silently producing a broken link. + +Do not write website-specific routes into canonical Markdown just to satisfy VitePress. Use `sourceAliases` for directory-style repository links that should resolve to a mapped index page. + +## Preview and validate + +Run local preview while editing: + +```sh +pnpm docs:dev +``` + +The dev server watches mapped source files and reprojects them. Restart it after changing the manifest if the new source is not picked up automatically. + +Run the focused website gate before treating the mapping as valid: + +```sh +pnpm docs:check +``` + +Before committing a documentation-site change, run: + +```sh +pnpm run doc-sync +pnpm run lint +git diff --check +``` + +Use [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md) before pushing. Report the canonical files changed, manifest entries added or removed, public routes affected, and the exact checks run. + +## Keep deployment separate + +Synchronizing content into the VitePress build does not publish it to the internet. Do not add GitHub Pages permissions, deployment workflows, custom domains, or public hosting unless the user explicitly requests deployment and confirms the hosting policy. diff --git a/.agents/skills/dsh-doc-site-sync/agents/openai.yaml b/.agents/skills/dsh-doc-site-sync/agents/openai.yaml new file mode 100644 index 0000000000..9f4909f258 --- /dev/null +++ b/.agents/skills/dsh-doc-site-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "DSH Documentation Site Sync" + short_description: "Publish repository docs through the DSH website manifest" + default_prompt: "Use $dsh-doc-site-sync to publish or update a DeepSeek Harness documentation page on the website." From 6be219a0bc7cd2554282fc41a565b5c399271071 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 13 Jul 2026 17:47:42 +0800 Subject: [PATCH 03/28] fix(docs): address documentation site review --- docs/user/zh-CN/develop/basic/config.md | 17 +- docs/user/zh-CN/develop/basic/index.md | 13 +- docs/user/zh-CN/develop/basic/tool.md | 12 +- docs/user/zh-CN/develop/framework/events.md | 50 ++- docs/user/zh-CN/develop/framework/index.md | 12 +- docs/user/zh-CN/develop/framework/service.md | 17 +- .../zh-CN/develop/practice/llm-adapter.md | 7 +- docs/user/zh-CN/index.md | 2 +- scripts/project-doc-site.spec.ts | 37 ++- scripts/project-doc-site.ts | 18 +- website/.vitepress/config.ts | 61 +++- website/docs.ts | 299 +++++++++++------- 12 files changed, 333 insertions(+), 212 deletions(-) diff --git a/docs/user/zh-CN/develop/basic/config.md b/docs/user/zh-CN/develop/basic/config.md index 6c5bf6c651..23294f7955 100644 --- a/docs/user/zh-CN/develop/basic/config.md +++ b/docs/user/zh-CN/develop/basic/config.md @@ -4,10 +4,11 @@ ## 定义 Config 类型 -在插件中导出一个 `Config` 类型和可选的默认值: +在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: ```typescript import type { Context } from 'cordis' +import Schema from 'schemastery' export const name = 'my-plugin' @@ -17,11 +18,11 @@ export interface Config { verbose?: boolean } -export const Config = { - greeting: 'Hello', - maxRetries: 3, - verbose: false, -} +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + maxRetries: Schema.number().default(3), + verbose: Schema.boolean().default(false), +}) export function apply(ctx: Context, config: Config) { console.log(config.greeting) // 用户配置或默认值 @@ -37,7 +38,7 @@ export function apply(ctx: Context, config: Config) { maxRetries: 5 ``` -未提供的字段使用导出的 `Config` 对象中的默认值。 +插件加载时,Cordis 会通过导出的 schema 校验配置,并填充未提供字段的默认值。不要导出普通对象作为 `Config`,因为它不满足 Cordis 要求的 Standard Schema 接口。 ## Schema 校验 @@ -92,7 +93,7 @@ export interface Config { ```typescript export function apply(ctx: Context, config: Config) { - if (!ctx.llm.hasAdapter(config.model)) { + if (!ctx.llm.models().includes(config.model)) { throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) } } diff --git a/docs/user/zh-CN/develop/basic/index.md b/docs/user/zh-CN/develop/basic/index.md index ce68283892..c1f7ab800b 100644 --- a/docs/user/zh-CN/develop/basic/index.md +++ b/docs/user/zh-CN/develop/basic/index.md @@ -28,10 +28,8 @@ import type { Context } from 'cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { - // 监听 agent-loop 的 ready 事件 - ctx.on('ready', () => { - console.log('[hello-plugin] 插件已加载!') - }) + // apply 被调用时,插件的必选依赖已就绪 + console.log('[hello-plugin] 插件已加载!') } ``` @@ -100,17 +98,14 @@ export default { ### 类形式 ```typescript -import { Service } from 'cordis' +import { Service, type Context } from 'cordis' export default class MyService extends Service { static inject = ['tools'] constructor(ctx: Context) { super(ctx, 'myService') - } - - start() { - // 服务启动逻辑 + // 构造函数内完成同步初始化 } } ``` diff --git a/docs/user/zh-CN/develop/basic/tool.md b/docs/user/zh-CN/develop/basic/tool.md index 47a3af7867..9eb4715385 100644 --- a/docs/user/zh-CN/develop/basic/tool.md +++ b/docs/user/zh-CN/develop/basic/tool.md @@ -133,14 +133,14 @@ defineTool({ // ... presentCall(args) { return { - intent: 'terminal', - title: `bash(${JSON.stringify(args.command).slice(0, 60)})`, + card: 'terminal', + title: args.command, } }, presentResult(args, result) { return { - intent: 'terminal', - body: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + card: 'terminal', + output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), } }, }) @@ -156,9 +156,7 @@ defineTool({ // 这样就够了: ctx.tools.register(defineTool({ /* ... */ })) -// 不需要: -// const dispose = ctx.tools.register(...) -// ctx.on('dispose', dispose) +// 不需要额外保存 disposer 或注册清理逻辑 ``` ## 完整实战示例 diff --git a/docs/user/zh-CN/develop/framework/events.md b/docs/user/zh-CN/develop/framework/events.md index 641c63b0f8..80f49d38dc 100644 --- a/docs/user/zh-CN/develop/framework/events.md +++ b/docs/user/zh-CN/develop/framework/events.md @@ -24,15 +24,15 @@ Cordis 提供多种事件触发模式,适用于不同场景: ### emit — 广播 -所有监听器并行执行,不关心返回值: +所有监听器同步执行,不关心返回值: ```typescript // 触发 -ctx.emit('agent/turn-end', { agentId, turnIndex }) +ctx.emit('my-plugin/ready', { id: 'worker-1' }) // 监听 -ctx.on('agent/turn-end', ({ agentId, turnIndex }) => { - console.log(`Turn ${turnIndex} ended`) +ctx.on('my-plugin/ready', ({ id }) => { + console.log(`${id} is ready`) }) ``` @@ -53,7 +53,7 @@ ctx.on('some-check', (input) => { ### serial — 顺序执行 -所有监听器按注册顺序依次执行(异步安全): +监听器按注册顺序依次执行,并等待异步结果;第一个返回非空值的监听器会终止后续执行: ```typescript await ctx.serial('setup-phase', context) @@ -61,18 +61,16 @@ await ctx.serial('setup-phase', context) ### waterfall — 管道 -每个监听器接收前一个的输出,形成数据管道。**必须调用 `next()` 传递给下游**,不调用即为否决: +每个监听器可以包装下游返回值,形成处理链。**必须调用 `next()` 传递给下游**,不调用即为否决: ```typescript // 触发 -const finalMessages = await ctx.waterfall('llm/pre-request', messages) +const output = await ctx.waterfall('my-plugin/transform', input, async () => input) // 监听(必须调用 next) -ctx.on('llm/pre-request', async (messages, next) => { - // 可以修改 messages - messages.push(extraMessage) - // 必须调用 next() 传递给下一个监听器 - return next(messages) +ctx.on('my-plugin/transform', async (_input, next) => { + const downstream = await next() + return downstream.trim() }) ``` @@ -89,6 +87,7 @@ declare module 'cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined + 'my-plugin/transform': (input: string, next: () => Promise) => Promise } } @@ -96,20 +95,11 @@ declare module 'cordis' { // 都有正确的类型推导 ``` -## 命名约定 +## Cordis 事件与会话记录 -Harness 事件遵循 `namespace/action` 命名: +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../../cordis-catalog/events.md)。 -``` -agent/pre-step — agent 执行一步之前 -agent/post-step — agent 执行一步之后 -tool/call — tool 被调用 -tool/result — tool 返回结果 -llm/pre-request — LLM 请求发送前 -session/event — 会话事件被记录 -compact/start — 压缩开始 -compact/end — 压缩结束 -``` +`turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 ## 事件也是效果 @@ -118,7 +108,7 @@ compact/end — 压缩结束 ```typescript export function apply(ctx: Context) { // 这个监听器在插件 dispose 时自动清理 - ctx.on('agent/turn-end', handler) + ctx.on('tools/result', handler) } ``` @@ -132,14 +122,10 @@ import type { Context } from 'cordis' export const name = 'tool-logger' export function apply(ctx: Context) { - ctx.on('tool/call', ({ name, args }) => { - console.log(`[tool] ${name}(${JSON.stringify(args)})`) - }) - - ctx.on('tool/result', ({ name, result }) => { + ctx.on('tools/result', (exec, result) => { + console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`) const text = result.content - .filter(b => b.type === 'text') - .map(b => b.text) + .map(block => block.type === 'text' ? block.text : '') .join('') console.log(`[tool result] ${text.slice(0, 100)}`) }) diff --git a/docs/user/zh-CN/develop/framework/index.md b/docs/user/zh-CN/develop/framework/index.md index b0547be61b..a3fdd502b5 100644 --- a/docs/user/zh-CN/develop/framework/index.md +++ b/docs/user/zh-CN/develop/framework/index.md @@ -105,14 +105,6 @@ fiber.dispose() export function apply(ctx: Context) { console.log('plugin loading') - ctx.on('ready', () => { - console.log('context ready') - }) - - ctx.on('dispose', () => { - console.log('plugin disposing') - }) - ctx.effect(() => { console.log('effect registered') return () => console.log('effect cleaned up') @@ -124,12 +116,10 @@ export function apply(ctx: Context) { ``` plugin loading effect registered -context ready ``` -卸载时输出(逆序): +卸载时输出: ``` -plugin disposing effect cleaned up ``` diff --git a/docs/user/zh-CN/develop/framework/service.md b/docs/user/zh-CN/develop/framework/service.md index 17b9cb4e4e..19edf4a975 100644 --- a/docs/user/zh-CN/develop/framework/service.md +++ b/docs/user/zh-CN/develop/framework/service.md @@ -90,8 +90,11 @@ export default class MetricsService extends Service { // 必选:服务不存在时,插件不会加载 export const inject = ['tools'] -// 可选:服务不存在时,插件仍然加载,但 ctx.xxx 可能是 undefined -export const inject = { optional: ['metrics'] } +// 可选:不写入 inject,使用时通过 ctx.get() 查询 +export function apply(ctx: Context) { + const metrics = ctx.get('metrics') + metrics?.record('plugin_loaded', 1) +} ``` ### 服务消失时的行为 @@ -109,7 +112,10 @@ export const inject = { optional: ['metrics'] } ```yaml - id: group-a - name: 'group:' + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true config: - name: '@deepseek-ai/dsh-bash-local' config: @@ -117,7 +123,10 @@ export const inject = { optional: ['metrics'] } - name: './src/plugin-a.ts' - id: group-b - name: 'group:' + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true config: - name: '@deepseek-ai/dsh-bash-local' config: diff --git a/docs/user/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md index 20b1fa2c88..0b0ae3cff0 100644 --- a/docs/user/zh-CN/develop/practice/llm-adapter.md +++ b/docs/user/zh-CN/develop/practice/llm-adapter.md @@ -114,6 +114,8 @@ interface GenerateOptions { maxTokens?: number /** 温度 */ temperature?: number + /** 取消或卸载时中止进行中的请求 */ + signal?: AbortSignal } ``` @@ -160,7 +162,10 @@ mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地 ```typescript async *stream(options: GenerateOptions): AsyncIterable { - const response = await fetch(this.endpoint, { /* ... */ }) + const response = await fetch(this.endpoint, { + // ...method、headers 和 body + signal: options.signal, + }) if (!response.ok) { throw new Error(`API error: ${response.status}`) } diff --git a/docs/user/zh-CN/index.md b/docs/user/zh-CN/index.md index cbf700e41e..1c495125c6 100644 --- a/docs/user/zh-CN/index.md +++ b/docs/user/zh-CN/index.md @@ -13,7 +13,7 @@ hero: link: /develop/basic/ features: - title: 插件化架构 - details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。 + details: 基于 Cordis 插件系统,所有能力通过插件注册,加载即生效、卸载即还原。 - title: 配置即组合 details: 一个 cordis.yml 决定整个 Agent 的能力组合——换模型、加工具,只需改一行配置。 - title: 开箱即用 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index f44b779d80..9a6162576d 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { DocsPage } from '../website/docs.ts' +import { docsPages, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' const roots: string[] = [] @@ -25,8 +25,10 @@ function fixture(): { root: string; pages: DocsPage[] } { return { root, pages: [ - { source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-docs', section: 'Test', order: 1 }, - { source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-docs', section: 'Test', order: 2 }, + { locale: 'root', contentLocale: 'en-US', source: 'docs/a.md', route: 'a.md', label: 'A', sidebar: 'zh-reference', section: 'Test', order: 1 }, + { locale: 'root', contentLocale: 'en-US', source: 'docs/b.md', route: 'reference-root/b.md', label: 'B', sidebar: 'zh-reference', section: 'Test', order: 2 }, + { locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', route: 'en/a.md', label: 'A', sidebar: 'en-reference', section: 'Test', order: 1 }, + { locale: 'en', contentLocale: 'en-US', source: 'docs/b.md', route: 'en/reference/b.md', label: 'B', sidebar: 'en-reference', section: 'Test', order: 2 }, ], } } @@ -36,6 +38,7 @@ describe('rewriteMarkdown', () => { const { root, pages } = fixture() const source = '[B](b.md#part) [source](../packages/tool.ts:2) [web](https://example.com)\n' expect(rewriteMarkdown(source, { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -48,9 +51,22 @@ describe('rewriteMarkdown', () => { ) }) + it('selects the published target in the current site locale', () => { + const { root, pages } = fixture() + expect(rewriteMarkdown('[B](b.md)\n', { + locale: 'root', + sourcePath: 'docs/a.md', + route: 'a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('[B](./reference-root/b.md)\n') + }) + it('uses raw GitHub content for unpublished images', () => { const { root, pages } = fixture() expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -63,6 +79,7 @@ describe('rewriteMarkdown', () => { const { root, pages } = fixture() const source = '```md\n[B](b.md)\n```\n' expect(rewriteMarkdown(source, { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -74,6 +91,7 @@ describe('rewriteMarkdown', () => { it('fails loud when a relative target is missing', () => { const { root, pages } = fixture() expect(() => rewriteMarkdown('[missing](missing.md)\n', { + locale: 'en', sourcePath: 'docs/a.md', route: 'en/a.md', pages, @@ -83,6 +101,19 @@ describe('rewriteMarkdown', () => { }) }) +describe('docsPages locale routes', () => { + it('publishes the same canonical source at every corresponding locale route', () => { + const byRoute = new Map(docsPages.map(page => [page.route, page])) + for (const page of docsPages.filter(page => page.locale === 'root')) { + const counterpart = byRoute.get(`en/${page.route}`) + expect(counterpart, page.route).toBeDefined() + expect(counterpart?.locale).toBe('en') + expect(counterpart?.source).toBe(page.source) + expect(counterpart?.contentLocale).toBe(page.contentLocale) + } + }) +}) + describe('addProjectionFrontmatter', () => { it('adds frontmatter to an ordinary Markdown page', () => { expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe( diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 89e35dfdc8..7ef35a6d28 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -11,7 +11,7 @@ import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' -import { docsPages, type DocsPage } from '../website/docs.ts' +import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' const root = resolve(import.meta.dirname, '..') @@ -25,6 +25,7 @@ interface Replacement { /** Inputs for rewriting one canonical Markdown page. */ export interface RewriteMarkdownOptions { + locale: DocsLocale sourcePath: string route: string pages: DocsPage[] @@ -62,14 +63,16 @@ function routeTarget(fromRoute: string, toRoute: string, suffix: string): string return `${target.startsWith('.') ? target : `./${target}`}${suffix}` } -function sourceMap(pages: DocsPage[]): Map { - const map = new Map() +function sourceMap(pages: DocsPage[]): Map> { + const map = new Map>() for (const page of pages) { for (const source of [page.source, ...(page.sourceAliases ?? [])]) { - if (map.has(source)) { - throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)}.`) + const localized = map.get(source) ?? new Map() + if (localized.has(page.locale)) { + throw new Error(`project-doc-site: duplicate source or alias ${JSON.stringify(source)} for locale ${JSON.stringify(page.locale)}.`) } - map.set(source, page) + localized.set(page.locale, page) + map.set(source, localized) } } return map @@ -132,7 +135,7 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) - const page = published.get(targetPath) + const page = published.get(targetPath)?.get(options.locale) const nextUrl = page === undefined ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') : routeTarget(options.route, page.route, suffix) @@ -205,6 +208,7 @@ export function projectDocs(): void { const markdown = readFileSync(sourceAbs, 'utf8') const projected = rewriteMarkdown(markdown, { sourcePath: page.source, + locale: page.locale, route: page.route, pages: docsPages, repoRoot: root, diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 71c276794a..b553d6b2da 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -13,6 +13,14 @@ const sectionOrder = [ '基础', '框架能力', '实战', + '概念', + '生成参考', + '数据结构', + '开发手册', + 'Guide', + 'Basics', + 'Framework', + 'Practice', 'Concepts', 'Generated reference', 'Data structures', @@ -20,7 +28,7 @@ const sectionOrder = [ ] function sidebar(collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { - const pages = docsPages.filter(page => page.sidebar === collection && page.route !== 'index.md') + const pages = docsPages.filter(page => page.sidebar === collection) const sections = new Map() for (const page of pages) { const entries = sections.get(page.section) ?? [] @@ -51,7 +59,36 @@ function escapeVueInterpolation(html: string): string { } const sharedTheme: Pick = { - search: { provider: 'local' }, + search: { + provider: 'local', + options: { + locales: { + root: { + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档', + }, + modal: { + displayDetails: '显示详细列表', + resetButtonTitle: '清除搜索', + backButtonTitle: '关闭搜索', + noResultsText: '未找到相关结果', + footer: { + selectText: '选择', + selectKeyAriaLabel: '回车键', + navigateText: '切换', + navigateUpKeyAriaLabel: '上方向键', + navigateDownKeyAriaLabel: '下方向键', + closeText: '关闭', + closeKeyAriaLabel: 'Esc 键', + }, + }, + }, + }, + }, + }, + }, socialLinks: [ { icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' }, ], @@ -81,14 +118,22 @@ export default withMermaid({ nav: [ { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, - { text: 'Reference', link: '/en/', activeMatch: '^/en/' }, + { text: '参考', link: '/reference/', activeMatch: '^/reference/' }, ], sidebar: { '/guide/': sidebar('zh-guide'), '/develop/': sidebar('zh-develop'), + '/reference/': sidebar('zh-reference'), }, outline: { label: '本页目录' }, docFooter: { prev: '上一篇', next: '下一篇' }, + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色主题', + darkModeSwitchTitle: '切换到深色主题', + sidebarMenuLabel: '菜单', + returnToTopLabel: '返回顶部', + langMenuLabel: '切换语言', + skipToContentLabel: '跳至内容', }, }, en: { @@ -97,12 +142,14 @@ export default withMermaid({ link: '/en/', themeConfig: { nav: [ - { text: 'Concepts', link: '/en/' }, - { text: 'Reference', link: '/en/config-catalog' }, - { text: '中文指南', link: '/guide/' }, + { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' }, + { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' }, + { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' }, ], sidebar: { - '/en/': sidebar('en-docs'), + '/en/guide/': sidebar('en-guide'), + '/en/develop/': sidebar('en-develop'), + '/en/reference/': sidebar('en-reference'), }, editLink: { pattern: ({ frontmatter }: PageData) => { diff --git a/website/docs.ts b/website/docs.ts index 53e74c2905..16d53e5b8a 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -1,20 +1,38 @@ /** * Canonical publication manifest for the documentation website. * - * Markdown stays in its owning repository tier. This manifest only maps a - * source file to its public route and navigation placement. + * Markdown stays in its owning repository tier. This manifest maps each + * canonical source into matching route trees for both site locales; when a + * translation is absent, both routes intentionally project the available + * source instead of copying Markdown. */ +/** Locale key used by the VitePress site. */ +export type DocsLocale = 'root' | 'en' + +/** Sidebar collection rendered for one locale and top-level module. */ +type DocsSidebar = + | 'zh-guide' + | 'zh-develop' + | 'zh-reference' + | 'en-guide' + | 'en-develop' + | 'en-reference' + /** A page projected into the VitePress source tree. */ export interface DocsPage { + /** VitePress locale whose route tree owns this projection. */ + locale: DocsLocale + /** Language of the canonical source currently projected at this route. */ + contentLocale: 'zh-CN' | 'en-US' /** Repository-relative canonical Markdown source. */ source: string /** VitePress route, including the `.md` suffix. */ route: string /** Navigation label shown in the sidebar. */ label: string - /** Sidebar collection that owns the page. */ - sidebar: 'zh-guide' | 'zh-develop' | 'en-docs' + /** Sidebar collection that owns the page, or null for a locale home page. */ + sidebar: DocsSidebar | null /** Section label within the sidebar. */ section: string /** Stable order within the section. */ @@ -23,191 +41,228 @@ export interface DocsPage { sourceAliases?: string[] } -const zhGuide: DocsPage[] = [ +interface MirroredPage { + source: string + route: string + contentLocale: DocsPage['contentLocale'] + label: Record + sidebar: Record + section: Record + order: number + sourceAliases?: string[] +} + +function mirroredPages(pages: MirroredPage[]): DocsPage[] { + return pages.flatMap(page => (['root', 'en'] as const).map(locale => ({ + locale, + contentLocale: page.contentLocale, + source: page.source, + route: locale === 'root' ? page.route : `en/${page.route}`, + label: page.label[locale], + sidebar: page.sidebar[locale], + section: page.section[locale], + order: page.order, + ...(page.sourceAliases === undefined ? {} : { sourceAliases: page.sourceAliases }), + }))) +} + +const homeAndGuide = mirroredPages([ { source: 'docs/user/zh-CN/index.md', route: 'index.md', - label: 'DeepSeek Harness', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: 'DeepSeek Harness', en: 'DeepSeek Harness' }, + sidebar: { root: null, en: null }, + section: { root: '首页', en: 'Home' }, order: 0, }, { source: 'docs/user/zh-CN/guide/index.md', route: 'guide/index.md', - label: '介绍', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '介绍', en: 'Introduction' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 1, sourceAliases: ['docs/user/zh-CN/guide'], }, { source: 'docs/user/zh-CN/guide/quickstart.md', route: 'guide/quickstart.md', - label: '快速开始', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '快速开始', en: 'Quick start' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 2, }, { source: 'docs/user/zh-CN/guide/config.md', route: 'guide/config.md', - label: '配置文件', - sidebar: 'zh-guide', - section: '入门', + contentLocale: 'zh-CN', + label: { root: '配置文件', en: 'Configuration' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, order: 3, }, -] +]) -const zhDevelop: DocsPage[] = [ +const develop = mirroredPages([ { source: 'docs/user/zh-CN/develop/basic/index.md', route: 'develop/basic/index.md', - label: '第一个插件', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '第一个插件', en: 'First plugin' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/basic'], }, { source: 'docs/user/zh-CN/develop/basic/tool.md', route: 'develop/basic/tool.md', - label: '开发一个 Tool', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '开发一个 Tool', en: 'Build a tool' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 2, }, { source: 'docs/user/zh-CN/develop/basic/config.md', route: 'develop/basic/config.md', - label: '插件配置', - sidebar: 'zh-develop', - section: '基础', + contentLocale: 'zh-CN', + label: { root: '插件配置', en: 'Plugin configuration' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '基础', en: 'Basics' }, order: 3, }, { source: 'docs/user/zh-CN/develop/framework/index.md', route: 'develop/framework/index.md', - label: '插件与生命周期', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '插件与生命周期', en: 'Plugin lifecycle' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/framework'], }, { source: 'docs/user/zh-CN/develop/framework/service.md', route: 'develop/framework/service.md', - label: '服务与依赖', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '服务与依赖', en: 'Services and dependencies' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 2, }, { source: 'docs/user/zh-CN/develop/framework/events.md', route: 'develop/framework/events.md', - label: '事件系统', - sidebar: 'zh-develop', - section: '框架能力', + contentLocale: 'zh-CN', + label: { root: '事件系统', en: 'Event system' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '框架能力', en: 'Framework' }, order: 3, }, { source: 'docs/user/zh-CN/develop/practice/index.md', route: 'develop/practice/index.md', - label: '能力的三层拆分', - sidebar: 'zh-develop', - section: '实战', + contentLocale: 'zh-CN', + label: { root: '能力的三层拆分', en: 'Capability layering' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '实战', en: 'Practice' }, order: 1, sourceAliases: ['docs/user/zh-CN/develop/practice'], }, { source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', route: 'develop/practice/llm-adapter.md', - label: 'LLM 适配器', - sidebar: 'zh-develop', - section: '实战', + contentLocale: 'zh-CN', + label: { root: 'LLM 适配器', en: 'LLM adapter' }, + sidebar: { root: 'zh-develop', en: 'en-develop' }, + section: { root: '实战', en: 'Practice' }, order: 2, }, -] +]) -const enOverview: DocsPage[] = ([ - ['docs/architecture.md', 'en/index.md', 'Architecture'], - ['docs/cordis-primer.md', 'en/cordis-primer.md', 'Cordis primer'], - ['docs/capability-seams.md', 'en/capability-seams.md', 'Capability services'], - ['docs/agent-lifecycle.md', 'en/agent-lifecycle.md', 'Agent lifecycle'], - ['docs/tool-execution-pipeline.md', 'en/tool-execution-pipeline.md', 'Tool execution'], -] as const).map(([source, route, label], order) => ({ - source, - route, - label, - sidebar: 'en-docs', - section: 'Concepts', - order, -})) - -const enCatalogs: DocsPage[] = ([ - ['docs/config-catalog.md', 'en/config-catalog.md', 'Plugin configuration'], - ['docs/tool-catalog.md', 'en/tool-catalog.md', 'Tool schemas'], - ['docs/cordis-catalog/services.md', 'en/cordis-catalog/services.md', 'Services'], - ['docs/cordis-catalog/events.md', 'en/cordis-catalog/events.md', 'Events'], - ['docs/persistence-catalog.md', 'en/persistence-catalog.md', 'Persistence events'], -] as const).map(([source, route, label], order) => ({ - source, - route, - label, - sidebar: 'en-docs', - section: 'Generated reference', - order, -})) - -const corePages = [ - ['core.md', 'Core data structures'], - ['session.md', 'Sessions'], - ['tools.md', 'Tools'], - ['llm-streaming.md', 'LLM streaming'], - ['bash.md', 'Bash execution'], - ['filesystem.md', 'Filesystem'], - ['code-runtime.md', 'Code runtime'], - ['compaction.md', 'Compaction'], - ['subagent.md', 'Subagents'], - ['workflow.md', 'Workflows'], - ['skills.md', 'Skills'], - ['approval.md', 'Approvals'], - ['user-interaction.md', 'User interaction'], - ['sandbox.md', 'Sandboxing'], - ['web.md', 'Web access'], - ['persistence.md', 'Session persistence'], -] as const - -const enCore: DocsPage[] = corePages.map(([file, label], order) => ({ - source: `docs/core-data-structures/${file}`, - route: `en/core-data-structures/${file}`, - label, - sidebar: 'en-docs', - section: 'Data structures', - order, - ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), -})) - -const enCookbook: DocsPage[] = ([ - ['adding-a-package.md', 'Adding a package'], - ['adding-a-tool.md', 'Adding a tool'], - ['adding-an-llm-adapter.md', 'Adding an LLM adapter'], - ['extension-cookbook.md', 'Extension patterns'], -] as const).map(([file, label], order) => ({ - source: `docs/cookbook/${file}`, - route: `en/cookbook/${file}`, - label, - sidebar: 'en-docs', - section: 'Cookbook', - order, -})) +const reference = mirroredPages([ + ...([ + ['docs/architecture.md', 'reference/index.md', '架构', 'Architecture'], + ['docs/cordis-primer.md', 'reference/cordis-primer.md', 'Cordis 入门', 'Cordis primer'], + ['docs/capability-seams.md', 'reference/capability-seams.md', '能力服务', 'Capability services'], + ['docs/agent-lifecycle.md', 'reference/agent-lifecycle.md', 'Agent 生命周期', 'Agent lifecycle'], + ['docs/tool-execution-pipeline.md', 'reference/tool-execution-pipeline.md', 'Tool 执行', 'Tool execution'], + ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + source, + route, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '概念', en: 'Concepts' }, + order, + })), + ...([ + ['docs/config-catalog.md', 'reference/config-catalog.md', '插件配置', 'Plugin configuration'], + ['docs/tool-catalog.md', 'reference/tool-catalog.md', 'Tool Schema', 'Tool schemas'], + ['docs/cordis-catalog/services.md', 'reference/cordis-catalog/services.md', '服务', 'Services'], + ['docs/cordis-catalog/events.md', 'reference/cordis-catalog/events.md', '事件', 'Events'], + ['docs/persistence-catalog.md', 'reference/persistence-catalog.md', '持久化事件', 'Persistence events'], + ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + source, + route, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '生成参考', en: 'Generated reference' }, + order, + })), + ...([ + ['core.md', '核心数据结构', 'Core data structures'], + ['scope.md', '作用域', 'Scopes'], + ['session.md', '会话', 'Sessions'], + ['system-prompt.md', '系统提示词', 'System prompts'], + ['tools.md', '工具', 'Tools'], + ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], + ['bash.md', 'Bash 执行', 'Bash execution'], + ['filesystem.md', '文件系统', 'Filesystem'], + ['code-runtime.md', '代码运行时', 'Code runtime'], + ['compaction.md', '上下文压缩', 'Compaction'], + ['subagent.md', '子代理', 'Subagents'], + ['workflow.md', '工作流', 'Workflows'], + ['skills.md', '技能', 'Skills'], + ['approval.md', '审批', 'Approvals'], + ['user-interaction.md', '用户交互', 'User interaction'], + ['sandbox.md', '沙箱', 'Sandboxing'], + ['web.md', 'Web 访问', 'Web access'], + ['persistence.md', '会话持久化', 'Session persistence'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/core-data-structures/${file}`, + route: `reference/core-data-structures/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '数据结构', en: 'Data structures' }, + order, + ...(file === 'core.md' ? { sourceAliases: ['docs/core-data-structures'] } : {}), + })), + ...([ + ['adding-a-package.md', '新增 Package', 'Adding a package'], + ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], + ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], + ['extension-cookbook.md', '扩展模式', 'Extension patterns'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/cookbook/${file}`, + route: `reference/cookbook/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: '开发手册', en: 'Cookbook' }, + order, + })), +]) /** Every canonical page published by the documentation website. */ export const docsPages: DocsPage[] = [ - ...zhGuide, - ...zhDevelop, - ...enOverview, - ...enCatalogs, - ...enCore, - ...enCookbook, + ...homeAndGuide, + ...develop, + ...reference, ] From 6af61c6f4e89dedeec5b60886eed16144a37cf10 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 15 Jul 2026 18:08:28 +0800 Subject: [PATCH 04/28] fix(docs): align site with bilingual source pairs --- .agents/skills/dsh-doc-site-sync/SKILL.md | 6 +- docs/AGENTS.md | 2 +- docs/user/develop/basic/config.i18n.yaml | 6 + docs/user/develop/basic/config.md | 111 ++++++++++ .../config.md => develop/basic/config.zh.md} | 20 +- docs/user/develop/basic/index.i18n.yaml | 6 + docs/user/develop/basic/index.md | 151 +++++++++++++ .../index.md => develop/basic/index.zh.md} | 36 +-- docs/user/develop/basic/tool.i18n.yaml | 6 + docs/user/develop/basic/tool.md | 208 ++++++++++++++++++ .../tool.md => develop/basic/tool.zh.md} | 69 +++--- docs/user/develop/framework/events.i18n.yaml | 6 + docs/user/develop/framework/events.md | 143 ++++++++++++ .../framework/events.zh.md} | 47 ++-- docs/user/develop/framework/index.i18n.yaml | 6 + docs/user/develop/framework/index.md | 131 +++++++++++ .../framework/index.zh.md} | 26 ++- docs/user/develop/framework/service.i18n.yaml | 6 + docs/user/develop/framework/service.md | 148 +++++++++++++ .../framework/service.zh.md} | 34 +-- docs/user/develop/practice/index.i18n.yaml | 6 + docs/user/develop/practice/index.md | 158 +++++++++++++ .../index.md => develop/practice/index.zh.md} | 18 +- .../develop/practice/llm-adapter.i18n.yaml | 6 + docs/user/develop/practice/llm-adapter.md | 185 ++++++++++++++++ docs/user/develop/practice/llm-adapter.zh.md | 185 ++++++++++++++++ docs/user/guide/config.i18n.yaml | 6 + docs/user/guide/config.md | 59 +++++ .../guide/config.md => guide/config.zh.md} | 10 +- docs/user/guide/index.i18n.yaml | 6 + docs/user/guide/index.md | 49 +++++ .../guide/index.md => guide/index.zh.md} | 6 +- docs/user/guide/quickstart.i18n.yaml | 6 + docs/user/guide/quickstart.md | 99 +++++++++ .../quickstart.md => guide/quickstart.zh.md} | 14 +- docs/user/index.i18n.yaml | 6 + docs/user/index.md | 25 +++ docs/user/{zh-CN/index.md => index.zh.md} | 4 + .../zh-CN/develop/practice/llm-adapter.md | 174 --------------- scripts/project-doc-site.spec.ts | 54 ++++- scripts/project-doc-site.ts | 99 ++++++++- scripts/translation-pairing.manifest.json | 12 + website/docs.ts | 110 +++++---- 43 files changed, 2112 insertions(+), 353 deletions(-) create mode 100644 docs/user/develop/basic/config.i18n.yaml create mode 100644 docs/user/develop/basic/config.md rename docs/user/{zh-CN/develop/basic/config.md => develop/basic/config.zh.md} (90%) create mode 100644 docs/user/develop/basic/index.i18n.yaml create mode 100644 docs/user/develop/basic/index.md rename docs/user/{zh-CN/develop/basic/index.md => develop/basic/index.zh.md} (83%) create mode 100644 docs/user/develop/basic/tool.i18n.yaml create mode 100644 docs/user/develop/basic/tool.md rename docs/user/{zh-CN/develop/basic/tool.md => develop/basic/tool.zh.md} (80%) create mode 100644 docs/user/develop/framework/events.i18n.yaml create mode 100644 docs/user/develop/framework/events.md rename docs/user/{zh-CN/develop/framework/events.md => develop/framework/events.zh.md} (83%) create mode 100644 docs/user/develop/framework/index.i18n.yaml create mode 100644 docs/user/develop/framework/index.md rename docs/user/{zh-CN/develop/framework/index.md => develop/framework/index.zh.md} (80%) create mode 100644 docs/user/develop/framework/service.i18n.yaml create mode 100644 docs/user/develop/framework/service.md rename docs/user/{zh-CN/develop/framework/service.md => develop/framework/service.zh.md} (80%) create mode 100644 docs/user/develop/practice/index.i18n.yaml create mode 100644 docs/user/develop/practice/index.md rename docs/user/{zh-CN/develop/practice/index.md => develop/practice/index.zh.md} (94%) create mode 100644 docs/user/develop/practice/llm-adapter.i18n.yaml create mode 100644 docs/user/develop/practice/llm-adapter.md create mode 100644 docs/user/develop/practice/llm-adapter.zh.md create mode 100644 docs/user/guide/config.i18n.yaml create mode 100644 docs/user/guide/config.md rename docs/user/{zh-CN/guide/config.md => guide/config.zh.md} (71%) create mode 100644 docs/user/guide/index.i18n.yaml create mode 100644 docs/user/guide/index.md rename docs/user/{zh-CN/guide/index.md => guide/index.zh.md} (94%) create mode 100644 docs/user/guide/quickstart.i18n.yaml create mode 100644 docs/user/guide/quickstart.md rename docs/user/{zh-CN/guide/quickstart.md => guide/quickstart.zh.md} (88%) create mode 100644 docs/user/index.i18n.yaml create mode 100644 docs/user/index.md rename docs/user/{zh-CN/index.md => index.zh.md} (93%) delete mode 100644 docs/user/zh-CN/develop/practice/llm-adapter.md diff --git a/.agents/skills/dsh-doc-site-sync/SKILL.md b/.agents/skills/dsh-doc-site-sync/SKILL.md index 0fdd9b9398..bee1b0dfe8 100644 --- a/.agents/skills/dsh-doc-site-sync/SKILL.md +++ b/.agents/skills/dsh-doc-site-sync/SKILL.md @@ -7,6 +7,8 @@ description: Use when publishing, updating, moving, or removing DeepSeek Harness Keep repository Markdown as the only editable content source. Treat the website as a tested projection: [website/docs.ts](../../../website/docs.ts) selects public pages, [scripts/project-doc-site.ts](../../../scripts/project-doc-site.ts) rewrites them into the disposable `website/.generated/` tree, and VitePress builds that tree. +Repository translations follow the sibling pairing contract: English `foo.md`, Chinese `foo.zh.md`, and `foo.i18n.yaml` live together. Never create `zh-CN/` or other locale directories for website content. The site route trees are independent of that source layout: `foo.zh.md` projects to the root route and `foo.md` projects to the matching `/en/` route. + ## Read the owning contracts - Read [docs/AGENTS.md](../../../docs/AGENTS.md) and use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) when deciding where content belongs or changing product documentation prose. @@ -28,7 +30,7 @@ Never edit or commit `website/.generated/`, `website/.cache/`, or `website/.dist Set every `DocsPage` field deliberately: -- `source`: repository-relative canonical Markdown path. +- `source`: repository-relative canonical Markdown path. For a complete bilingual pair, add the English `.md` path through `pairedPages()`; it derives the sibling `.zh.md`, the content locales, and counterpart aliases. - `route`: public VitePress path including the `.md` suffix. - `label`: sidebar label, not necessarily the document H1. - `sidebar`: reuse `zh-guide`, `zh-develop`, or `en-docs` unless the information architecture genuinely needs another collection. @@ -36,7 +38,7 @@ Set every `DocsPage` field deliberately: - `order`: stable order within the section. - `sourceAliases`: optional additional repository paths that should resolve to this page when links are projected. It does not create another public route. -Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. +Use `mirroredPages()` only for a source that intentionally falls back to the same available language in both route trees. Convert that entry to `pairedPages()` when its counterpart is added. Keep the manifest an explicit public allowlist. Do not publish RFCs, postmortems, testing guides, `AGENTS.md`, or maintainer workflows merely because they exist under `docs/`; add internal material only when the user explicitly changes the publication boundary. ## Preserve link behavior diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 42f46b64e4..2723434e64 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,7 +15,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | [rfc/](rfc/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` RFCs describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | -| [user/](user/zh-CN/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | +| [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml new file mode 100644 index 0000000000..e4b71a7353 --- /dev/null +++ b/docs/user/develop/basic/config.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 +config.md: 5c4e712e2ae452d30fde23bd2481f0c5260ee526 +config.zh.md: 23c97e18a119a92fe7ae883621cb9340ef54bf80 diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md new file mode 100644 index 0000000000..5c4e712e2a --- /dev/null +++ b/docs/user/develop/basic/config.md @@ -0,0 +1,111 @@ +# Plugin configuration + +English | [中文](config.zh.md) + +Accept configuration supplied through `cordis.yml`. + +## Define the Config type + +Export a `Config` type and a same-named Schemastery schema. Put defaults directly on the schema fields: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'my-plugin' + +export interface Config { + greeting: string + maxRetries: number + verbose?: boolean +} + +export const Config: Schema = Schema.object({ + greeting: Schema.string().default('Hello'), + maxRetries: Schema.number().default(3), + verbose: Schema.boolean().default(false), +}) + +export function apply(ctx: Context, config: Config) { + console.log(config.greeting) // User value or schema default. +} +``` + +Configure it in `cordis.yml`: + +```yaml +- name: './src/my-plugin.ts' + config: + greeting: 'Hi there' + maxRetries: 5 +``` + +When loading the plugin, Cordis uses the exported schema to validate configuration and fill defaults. Do not export a plain object as `Config`; it does not implement the Standard Schema interface required by Cordis. + +## Schema validation + +Use Schemastery to express stricter validation: + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' + +export const name = 'validated-plugin' + +export interface Config { + apiKey: string + timeout: number + mode: 'fast' | 'accurate' +} + +export const Config = Schema.object({ + apiKey: Schema.string().required(), + timeout: Schema.number().default(30000), + mode: Schema.union(['fast', 'accurate']).default('fast'), +}) + +export function apply(ctx: Context, config: Config) { + // config is validated and type-safe. +} +``` + +The schema runs while the plugin loads. Invalid configuration fails the load with an actionable error. + +## Design principles + +### Do not hardcode tunable values + +Harness requires **anything that two deployments may want to set differently to be a configuration field**. + +```ts +// Wrong: hardcoded timeout. +const TIMEOUT = 30000 + +// Correct: configurable. +export interface Config { + timeoutMs: number // Defaults to 30000. +} +``` + +The test is whether `cordis.yml` can change the value without a code edit. + +### Fail loudly on invalid configuration + +If configuration refers to a missing model or another nonexistent resource, fail early instead of silently skipping it: + +```ts ignore-check +export function apply(ctx: Context, config: Config) { + if (!ctx.llm.models().includes(config.model)) { + throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) + } +} +``` + +## Work with HMR + +A configuration edit hot-replaces the plugin: the framework unloads the old instance and loads a new one. Because registrations are effects and clean themselves up, replacement does not retain the old instance's registrations. + +## Next steps + +- [Plugins and lifecycle](../framework/) — understand the full plugin lifecycle +- [Services and dependencies](../framework/service.md) — provide a service to other plugins diff --git a/docs/user/zh-CN/develop/basic/config.md b/docs/user/develop/basic/config.zh.md similarity index 90% rename from docs/user/zh-CN/develop/basic/config.md rename to docs/user/develop/basic/config.zh.md index 23294f7955..23c97e18a1 100644 --- a/docs/user/zh-CN/develop/basic/config.md +++ b/docs/user/develop/basic/config.zh.md @@ -1,12 +1,14 @@ # 插件配置 +[English](config.md) | 中文 + 让你的插件接受用户在 `cordis.yml` 中传入的配置。 ## 定义 Config 类型 在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: -```typescript +```ts import type { Context } from 'cordis' import Schema from 'schemastery' @@ -25,7 +27,7 @@ export const Config: Schema = Schema.object({ }) export function apply(ctx: Context, config: Config) { - console.log(config.greeting) // 用户配置或默认值 + console.log(config.greeting) // User value or schema default. } ``` @@ -44,7 +46,7 @@ export function apply(ctx: Context, config: Config) { 对于需要严格校验的场景,使用 Schemastery 定义 schema: -```typescript +```ts import type { Context } from 'cordis' import Schema from 'schemastery' @@ -63,7 +65,7 @@ export const Config = Schema.object({ }) export function apply(ctx: Context, config: Config) { - // config 已经过校验,类型安全 + // config is validated and type-safe. } ``` @@ -75,13 +77,13 @@ Schema 在插件加载时执行校验。如果配置不合法,插件会加载 Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。 -```typescript -// 错误 — 硬编码超时时间 +```ts +// Wrong: hardcoded timeout. const TIMEOUT = 30000 -// 正确 — 可配置 +// Correct: configurable. export interface Config { - timeoutMs: number // 默认 30000 + timeoutMs: number // Defaults to 30000. } ``` @@ -91,7 +93,7 @@ export interface Config { 如果配置引用了不存在的东西(比如一个不存在的模型名),应该尽早报错,而不是静默跳过: -```typescript +```ts ignore-check export function apply(ctx: Context, config: Config) { if (!ctx.llm.models().includes(config.model)) { throw new Error(`Model "${config.model}" is not registered by any LLM adapter`) diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml new file mode 100644 index 0000000000..22b03af93e --- /dev/null +++ b/docs/user/develop/basic/index.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 +index.md: 5fa46806bc195ad2566fc0a29b45eb1dd7a68179 +index.zh.md: a6d238c12841c8c25b00376ee032e5db50fc6b4e diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md new file mode 100644 index 0000000000..5fa46806bc --- /dev/null +++ b/docs/user/develop/basic/index.md @@ -0,0 +1,151 @@ +# Your first plugin + +English | [中文](index.zh.md) + +This guide creates a minimal Harness plugin and loads it into an agent. + +## What is a plugin? + +In Harness, a plugin is a TypeScript module that exports an `apply` function. The framework calls `apply` when loading the plugin and passes a `ctx` context object through which the plugin registers capabilities: + +```ts +import type { Context } from 'cordis' + +export const name = 'my-plugin' + +export function apply(ctx: Context) { + // Register capabilities here. +} +``` + +That is the complete shape. + +## Create the plugin file + +Create `src/my-plugin.ts` in your project: + +```ts +import type { Context } from 'cordis' + +export const name = 'hello-plugin' + +export function apply(ctx: Context) { + // Required dependencies are ready before apply runs. + console.log('[hello-plugin] plugin loaded!') +} +``` + +## Register it in cordis.yml + +Add an entry to `cordis.yml`: + +```yaml +- id: hello + name: './src/my-plugin.ts' +``` + +After startup, the console prints `[hello-plugin] plugin loaded!`. + +## Automatic cleanup + +Anything registered through `ctx`—event listeners, tools, or timers—is cleaned up when the plugin unloads. You do not need to call removeListener or clearInterval manually. + +For a resource that needs explicit cleanup, such as a network connection, use `ctx.effect()` to provide its disposer: + +```ts +import type { Context } from 'cordis' + +export function apply(ctx: Context) { + ctx.effect(() => { + const timer = setInterval(() => { + console.log('heartbeat') + }, 5000) + + // The returned function runs when the plugin unloads. + return () => clearInterval(timer) + }) +} +``` + +## Declare dependencies + +If the plugin consumes another service such as `tools` or `llm`, declare it in `inject`: + +```ts ignore-check +import type { Context } from 'cordis' + +export const name = 'my-tool-plugin' +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools is ready here. + ctx.tools.register(/* ... */) +} +``` + +The framework waits for every required service before loading the plugin. + +## Three plugin forms + +In addition to a function module, a plugin can use object or class form. + +### Object form + +```ts +import type { Context } from 'cordis' + +export default { + name: 'my-plugin', + inject: ['tools'], + apply(ctx: Context) { + // ... + }, +} +``` + +### Class form + +```ts +import { Service, type Context } from 'cordis' + +export default class MyService extends Service { + static inject = ['tools'] + + constructor(ctx: Context) { + super(ctx, 'myService') + // Perform synchronous initialization in the constructor. + } +} +``` + +Function form is sufficient in most cases. Use class form when the plugin provides a service to other plugins; see [services and dependencies](../framework/service.md). + +## Complete example + +`examples/echo-agent/src/echo-tool.ts` is a plugin that registers a tool: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'echo-tool' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'echo', + description: 'Echo the given text back, uppercased.', + parameters: { + text: { type: 'string', required: true }, + }, + async execute(args) { + return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }] + }, + })) +} +``` + +## Next steps + +- [Build a tool](./tool.md) — learn the tool definition DSL +- [Plugin configuration](./config.md) — accept user configuration diff --git a/docs/user/zh-CN/develop/basic/index.md b/docs/user/develop/basic/index.zh.md similarity index 83% rename from docs/user/zh-CN/develop/basic/index.md rename to docs/user/develop/basic/index.zh.md index c1f7ab800b..a6d238c128 100644 --- a/docs/user/zh-CN/develop/basic/index.md +++ b/docs/user/develop/basic/index.zh.md @@ -1,18 +1,20 @@ # 第一个插件 +[English](index.md) | 中文 + 本文带你编写一个最小的 Harness 插件并加载到 Agent 中。 ## 插件是什么 在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: -```typescript +```ts import type { Context } from 'cordis' export const name = 'my-plugin' export function apply(ctx: Context) { - // 在这里注册能力 + // Register capabilities here. } ``` @@ -22,14 +24,14 @@ export function apply(ctx: Context) { 在你的项目目录下创建 `src/my-plugin.ts`: -```typescript +```ts import type { Context } from 'cordis' export const name = 'hello-plugin' export function apply(ctx: Context) { - // apply 被调用时,插件的必选依赖已就绪 - console.log('[hello-plugin] 插件已加载!') + // Required dependencies are ready before apply runs. + console.log('[hello-plugin] plugin loaded!') } ``` @@ -42,7 +44,7 @@ export function apply(ctx: Context) { name: './src/my-plugin.ts' ``` -启动后你会在控制台看到 `[hello-plugin] 插件已加载!`。 +启动后你会在控制台看到 `[hello-plugin] plugin loaded!`。 ## 自动清理 @@ -50,14 +52,16 @@ export function apply(ctx: Context) { 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: -```typescript +```ts +import type { Context } from 'cordis' + export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => { console.log('heartbeat') }, 5000) - // 返回的函数会在插件卸载时被调用 + // The returned function runs when the plugin unloads. return () => clearInterval(timer) }) } @@ -67,12 +71,14 @@ export function apply(ctx: Context) { 如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: -```typescript +```ts ignore-check +import type { Context } from 'cordis' + export const name = 'my-tool-plugin' export const inject = ['tools'] export function apply(ctx: Context) { - // ctx.tools 现在可用 + // ctx.tools is ready here. ctx.tools.register(/* ... */) } ``` @@ -85,7 +91,9 @@ export function apply(ctx: Context) { ### 对象形式 -```typescript +```ts +import type { Context } from 'cordis' + export default { name: 'my-plugin', inject: ['tools'], @@ -97,7 +105,7 @@ export default { ### 类形式 -```typescript +```ts import { Service, type Context } from 'cordis' export default class MyService extends Service { @@ -105,7 +113,7 @@ export default class MyService extends Service { constructor(ctx: Context) { super(ctx, 'myService') - // 构造函数内完成同步初始化 + // Perform synchronous initialization in the constructor. } } ``` @@ -116,7 +124,7 @@ export default class MyService extends Service { 参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml new file mode 100644 index 0000000000..d2f4343cf1 --- /dev/null +++ b/docs/user/develop/basic/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 +tool.md: 416733bcb584fa5303a8b3ba5e6e904302e7f992 +tool.zh.md: fce9a7d9b973853c8b4fb9ae2c034e749d8da999 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md new file mode 100644 index 0000000000..416733bcb5 --- /dev/null +++ b/docs/user/develop/basic/tool.md @@ -0,0 +1,208 @@ +# Build a tool + +English | [中文](tool.zh.md) + +A tool is a capability the model can call. This guide builds one with `defineTool`. + +## Minimal example + +```ts +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: 'greet', + description: 'Greet someone by name.', + parameters: { + name: { type: 'string', required: true, description: 'The name to greet' }, + }, + async execute(args) { + // args is inferred as { name: string }. + return [{ type: 'text', text: `Hello, ${args.name}!` }] + }, + })) +} +``` + +## Parameter definitions + +`parameters` uses a compact format that the framework converts to the JSON Schema sent to the model. + +### Primitive types + +```ts +export const parameters = { + path: { type: 'string', required: true }, + limit: { type: 'number' }, + recursive: { type: 'boolean' }, +} +// Inferred type: { path: string; limit?: number; recursive?: boolean } +``` + +### Enums + +```ts +export const parameters = { + mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, +} +// Inferred type: { mode: string } (enum values are validated at runtime) +``` + +### Nested objects + +```ts +export const parameters = { + options: { + type: 'object', + properties: { + timeout: { type: 'number' }, + retries: { type: 'number' }, + }, + }, +} +// Inferred type: { options?: { timeout?: number; retries?: number } } +``` + +### Arrays + +```ts +export const parameters = { + tags: { + type: 'array', + items: { type: 'string' }, + }, +} +// Inferred type: { tags?: string[] } +``` + +### Property fields + +| Field | Type | Meaning | +|------|------|------| +| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | Value type | +| `required` | `true` | Marks the property required and affects inference | +| `description` | `string` | Description sent to the model | +| `enum` | `string[]` | Allowed string values | +| `properties` | `SchemaSpec` | Nested properties for an object | +| `items` | `SchemaProp` | Element schema for an array | + +## The execute function + +`execute` receives validated, inferred `args` and an `exec` execution context: + +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const tool = defineTool({ + name: 'example', + description: 'Return an example result.', + parameters: {}, + async execute(args, exec) { + // args: inferred from parameters + // exec: ToolExecution context + + // Return a ContentBlock array. + void args + void exec + return [{ type: 'text', text: 'result here' }] + }, +}) +``` + +### Return value + +`execute` returns a `ContentBlock[]` that becomes the tool result visible to the model: + +```ts ignore-check +// Text result +return [{ type: 'text', text: 'file content here...' }] + +// Multiple blocks +return [ + { type: 'text', text: 'Found 3 matches:' }, + { type: 'text', text: matchResults.join('\n') }, +] +``` + +### Argument validation + +Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call. + +Do not repeat type validation inside `execute`. + +## Presentation + +A tool can define UI presentation methods for terminal and ACP clients: + +```ts ignore-check +defineTool({ + name: 'bash', + // ... + presentCall(args) { + return { + card: 'terminal', + title: args.command, + } + }, + presentResult(args, result) { + return { + card: 'terminal', + output: result.content.map(b => b.type === 'text' ? b.text : '').join(''), + } + }, +}) +``` + +`presentCall` and `presentResult` are **pure functions**. Streaming UI and session replay may call them more than once. + +## Registration and unloading + +`ctx.tools.register()` returns a disposer, but a registration made through `ctx` is already tracked by the framework. Unloading the plugin removes the tool automatically, so the plugin does not call the disposer itself. + +```ts ignore-check +// This is sufficient: +ctx.tools.register(defineTool({ /* ... */ })) + +// No saved disposer or extra cleanup registration is needed. +``` + +## Complete example + +This tool counts files in a directory: + +```ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { readdir } from 'node:fs/promises' + +export const name = 'file-counter' +export const inject = ['tools'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'count_files', + description: 'Count files in a directory.', + parameters: { + path: { type: 'string', required: true, description: 'Directory path' }, + extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' }, + }, + async execute(args) { + const entries = await readdir(args.path, { withFileTypes: true }) + let files = entries.filter(e => e.isFile()) + if (args.extension) { + files = files.filter(f => f.name.endsWith(args.extension!)) + } + return [{ type: 'text', text: `Found ${files.length} files.` }] + }, + })) +} +``` + +## Next steps + +- [Plugin configuration](./config.md) — make the tool configurable +- [Capability layering](../practice/) — understand the interface/implementation/consumer pattern diff --git a/docs/user/zh-CN/develop/basic/tool.md b/docs/user/develop/basic/tool.zh.md similarity index 80% rename from docs/user/zh-CN/develop/basic/tool.md rename to docs/user/develop/basic/tool.zh.md index 9eb4715385..fce9a7d9b9 100644 --- a/docs/user/zh-CN/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.zh.md @@ -1,10 +1,12 @@ # 开发一个 Tool +[English](tool.md) | 中文 + Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个 tool。 ## 最小示例 -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -19,7 +21,7 @@ export function apply(ctx: Context) { name: { type: 'string', required: true, description: 'The name to greet' }, }, async execute(args) { - // args 自动推导为 { name: string } + // args is inferred as { name: string }. return [{ type: 'text', text: `Hello, ${args.name}!` }] }, })) @@ -32,28 +34,28 @@ export function apply(ctx: Context) { ### 基本类型 -```typescript -parameters: { +```ts +export const parameters = { path: { type: 'string', required: true }, limit: { type: 'number' }, recursive: { type: 'boolean' }, } -// 推导类型: { path: string; limit?: number; recursive?: boolean } +// Inferred type: { path: string; limit?: number; recursive?: boolean } ``` ### 枚举 -```typescript -parameters: { +```ts +export const parameters = { mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] }, } -// 推导类型: { mode: string } (运行时校验 enum 值) +// Inferred type: { mode: string } (enum values are validated at runtime) ``` ### 嵌套对象 -```typescript -parameters: { +```ts +export const parameters = { options: { type: 'object', properties: { @@ -62,19 +64,19 @@ parameters: { }, }, } -// 推导类型: { options?: { timeout?: number; retries?: number } } +// Inferred type: { options?: { timeout?: number; retries?: number } } ``` ### 数组 -```typescript -parameters: { +```ts +export const parameters = { tags: { type: 'array', items: { type: 'string' }, }, } -// 推导类型: { tags?: string[] } +// Inferred type: { tags?: string[] } ``` ### 每个属性的字段 @@ -92,25 +94,34 @@ parameters: { `execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象: -```typescript -async execute(args, exec) { - // args: 根据 parameters 自动推导的类型 - // exec: ToolExecution 对象,提供执行上下文 +```ts +import { defineTool } from '@deepseek-ai/dsh-tools' - // 返回 ContentBlock 数组 - return [{ type: 'text', text: 'result here' }] -} +export const tool = defineTool({ + name: 'example', + description: 'Return an example result.', + parameters: {}, + async execute(args, exec) { + // args: inferred from parameters + // exec: ToolExecution context + + // Return a ContentBlock array. + void args + void exec + return [{ type: 'text', text: 'result here' }] + }, +}) ``` ### 返回值 `execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果: -```typescript -// 文本结果 +```ts ignore-check +// Text result return [{ type: 'text', text: 'file content here...' }] -// 多个 block +// Multiple blocks return [ { type: 'text', text: 'Found 3 matches:' }, { type: 'text', text: matchResults.join('\n') }, @@ -127,7 +138,7 @@ return [ Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 tool call 和 result: -```typescript +```ts ignore-check defineTool({ name: 'bash', // ... @@ -152,18 +163,18 @@ defineTool({ `ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。 -```typescript -// 这样就够了: +```ts ignore-check +// This is sufficient: ctx.tools.register(defineTool({ /* ... */ })) -// 不需要额外保存 disposer 或注册清理逻辑 +// No saved disposer or extra cleanup registration is needed. ``` ## 完整实战示例 一个文件计数 tool: -```typescript +```ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { readdir } from 'node:fs/promises' diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml new file mode 100644 index 0000000000..9704eff7c5 --- /dev/null +++ b/docs/user/develop/framework/events.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 +events.md: 0c57681a55ea0200fe8f33293176fc94f09a4ce5 +events.zh.md: 3e14739d4a97ba014d545c9f226000507aaeacef diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md new file mode 100644 index 0000000000..0c57681a55 --- /dev/null +++ b/docs/user/develop/framework/events.md @@ -0,0 +1,143 @@ +# Event system + +English | [中文](events.zh.md) + +Events are the core communication mechanism between Cordis plugins. Harness uses them extensively for loosely coupled extension points. + +## Basic use + +### Listen for an event + +```ts ignore-check +ctx.on('event-name', (payload) => { + // Handle the event. +}) +``` + +### Emit an event + +```ts ignore-check +ctx.emit('event-name', payload) +``` + +## Event modes + +Cordis provides several event modes for different interaction contracts. + +### emit — broadcast + +Every listener runs synchronously and return values are ignored: + +```ts ignore-check +// Emit +ctx.emit('my-plugin/ready', { id: 'worker-1' }) + +// Listen +ctx.on('my-plugin/ready', ({ id }) => { + console.log(`${id} is ready`) +}) +``` + +### bail — short circuit + +Listeners run in order; the first non-`undefined` result becomes the final result: + +```ts ignore-check +// Dispatch +const result = ctx.bail('some-check', input) + +// Listen: a returned value stops later listeners. +ctx.on('some-check', (input) => { + if (shouldBlock(input)) return 'blocked' + // Return undefined to continue to the next listener. +}) +``` + +### serial — ordered execution + +Listeners run in registration order and asynchronous results are awaited. The first listener to return a non-empty value stops further execution: + +```ts ignore-check +await ctx.serial('setup-phase', context) +``` + +### waterfall — pipeline + +Each listener may wrap the downstream result to form a processing chain. A listener **must call `next()` to delegate downstream**; omitting the call vetoes the pipeline: + +```ts ignore-check +// Dispatch +const output = await ctx.waterfall('my-plugin/transform', input, async () => input) + +// Listen: next() is mandatory. +ctx.on('my-plugin/transform', async (_input, next) => { + const downstream = await next() + return downstream.trim() +}) +``` + +::: warning +A waterfall listener **must call `next()`**. Omitting it vetoes the pipeline by design, enabling interception and gateway behavior. +::: + +## Typed events + +Harness uses TypeScript declaration merging for type-safe events: + +```ts +import 'cordis' + +declare module 'cordis' { + interface Events { + 'my-plugin/ready': (payload: { id: string }) => void + 'my-plugin/check': (input: string) => boolean | undefined + 'my-plugin/transform': (input: string, next: () => Promise) => Promise + } +} + +// ctx.on('my-plugin/ready', ...) and ctx.emit('my-plugin/ready', ...) +// are now inferred correctly. +``` + +## Cordis events and session records + +Harness Cordis events use `namespace/action` names, including `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/result`, and `session/event`. The generated [event catalog](../../../cordis-catalog/events.md) records complete signatures and modes. + +`turn/*`, `step/*`, `tool/call`, `tool/result`, and `compact/*` are durable session-event types, not same-named Cordis events. To observe them, listen to `session/event` and inspect `event.type`. + +## Event listeners are effects + +A listener registered with `ctx.on()` is removed automatically when its plugin unloads: + +```ts ignore-check +export function apply(ctx: Context) { + // This listener is removed when the plugin disposes. + ctx.on('tools/result', handler) +} +``` + +## Example: logging plugin + +This plugin logs tool calls and results: + +```ts +import type { Context } from 'cordis' +import '@deepseek-ai/dsh-tools' + +export const name = 'tool-logger' + +export function apply(ctx: Context) { + ctx.on('tools/result', (exec, result) => { + console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`) + const text = result.content + .map(block => block.type === 'text' ? block.text : '') + .join('') + console.log(`[tool result] ${text.slice(0, 100)}`) + }) +} +``` + +## Next steps + +- [Capability layering](../practice/) — understand events within capability interfaces +- [LLM adapters](../practice/llm-adapter.md) — implement a complete LLM backend diff --git a/docs/user/zh-CN/develop/framework/events.md b/docs/user/develop/framework/events.zh.md similarity index 83% rename from docs/user/zh-CN/develop/framework/events.md rename to docs/user/develop/framework/events.zh.md index 80f49d38dc..3e14739d4a 100644 --- a/docs/user/zh-CN/develop/framework/events.md +++ b/docs/user/develop/framework/events.zh.md @@ -1,20 +1,22 @@ # 事件系统 +[English](events.md) | 中文 + 事件是 Cordis 插件间通信的核心机制。Harness 大量使用事件来实现松耦合的扩展点。 ## 基本用法 ### 监听事件 -```typescript +```ts ignore-check ctx.on('event-name', (payload) => { - // 处理事件 + // Handle the event. }) ``` ### 触发事件 -```typescript +```ts ignore-check ctx.emit('event-name', payload) ``` @@ -26,11 +28,11 @@ Cordis 提供多种事件触发模式,适用于不同场景: 所有监听器同步执行,不关心返回值: -```typescript -// 触发 +```ts ignore-check +// Emit ctx.emit('my-plugin/ready', { id: 'worker-1' }) -// 监听 +// Listen ctx.on('my-plugin/ready', ({ id }) => { console.log(`${id} is ready`) }) @@ -40,14 +42,14 @@ ctx.on('my-plugin/ready', ({ id }) => { 依次调用监听器,第一个返回非 `undefined` 值的结果作为最终值: -```typescript -// 触发 +```ts ignore-check +// Dispatch const result = ctx.bail('some-check', input) -// 监听(返回值阻止后续监听器) +// Listen: a returned value stops later listeners. ctx.on('some-check', (input) => { if (shouldBlock(input)) return 'blocked' - // 返回 undefined 继续传递给下一个监听器 + // Return undefined to continue to the next listener. }) ``` @@ -55,7 +57,7 @@ ctx.on('some-check', (input) => { 监听器按注册顺序依次执行,并等待异步结果;第一个返回非空值的监听器会终止后续执行: -```typescript +```ts ignore-check await ctx.serial('setup-phase', context) ``` @@ -63,11 +65,11 @@ await ctx.serial('setup-phase', context) 每个监听器可以包装下游返回值,形成处理链。**必须调用 `next()` 传递给下游**,不调用即为否决: -```typescript -// 触发 +```ts ignore-check +// Dispatch const output = await ctx.waterfall('my-plugin/transform', input, async () => input) -// 监听(必须调用 next) +// Listen: next() is mandatory. ctx.on('my-plugin/transform', async (_input, next) => { const downstream = await next() return downstream.trim() @@ -82,7 +84,9 @@ Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整 Harness 使用 TypeScript 声明合并来为事件提供类型安全: -```typescript +```ts +import 'cordis' + declare module 'cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void @@ -91,13 +95,13 @@ declare module 'cordis' { } } -// 现在 ctx.on('my-plugin/ready', ...) 和 ctx.emit('my-plugin/ready', ...) -// 都有正确的类型推导 +// ctx.on('my-plugin/ready', ...) and ctx.emit('my-plugin/ready', ...) +// are now inferred correctly. ``` ## Cordis 事件与会话记录 -Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../../cordis-catalog/events.md)。 +Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-step`、`agent/request`、`agent/step-result`、`tools/result` 和 `session/event`。完整签名与触发模式见[Events 目录](../../../cordis-catalog/events.md)。 `turn/*`、`step/*`、`tool/call`、`tool/result` 和 `compact/*` 是持久化的会话事件类型,不是同名 Cordis 事件。需要观察它们时,监听 `session/event` 并检查 `event.type`。 @@ -105,9 +109,9 @@ Harness 的 Cordis 事件遵循 `namespace/action` 命名,例如 `agent/pre-st 通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 这个监听器在插件 dispose 时自动清理 + // This listener is removed when the plugin disposes. ctx.on('tools/result', handler) } ``` @@ -116,8 +120,9 @@ export function apply(ctx: Context) { 一个记录所有 tool 调用的简单插件: -```typescript +```ts import type { Context } from 'cordis' +import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml new file mode 100644 index 0000000000..79c947dcb9 --- /dev/null +++ b/docs/user/develop/framework/index.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 +index.md: bb08e7cb3f9d3a094100806451fd33d1482003cb +index.zh.md: 4b9d6d22c8d82bece67c71032a0028b21939f98f diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md new file mode 100644 index 0000000000..bb08e7cb3f --- /dev/null +++ b/docs/user/develop/framework/index.md @@ -0,0 +1,131 @@ +# Plugins and lifecycle + +English | [中文](index.zh.md) + +This page describes the Cordis plugin model and lifecycle state machine. + +## Fiber state machine + +Every loaded plugin owns a **Fiber** scope with the following states: + +``` +PENDING → LOADING → ACTIVE + ↘ FAILED +ACTIVE → UNLOADING → DISPOSED +``` + +| State | Meaning | +|------|------| +| PENDING | Declared, but required dependencies are not ready | +| LOADING | Dependencies are ready and `apply` is running | +| ACTIVE | The plugin is running | +| FAILED | `apply` threw an error | +| UNLOADING | The plugin is unloading and disposing resources | +| DISPOSED | The plugin is fully unloaded | + +## Dependency-driven loading + +A plugin with `inject` waits for every required service before loading: + +```ts ignore-check +export const inject = ['tools', 'llm'] + +export function apply(ctx: Context) { + // ctx.tools and ctx.llm are ready here. +} +``` + +If a required service disappears, for example during provider replacement, the plugin unloads automatically (ACTIVE → DISPOSED) and loads again when the service returns. + +## Automatic cleanup + +Every registration made through `ctx` is undone when the plugin unloads: + +```ts ignore-check +export function apply(ctx: Context) { + // Event listener: removed automatically on unload. + ctx.on('some-event', handler) + + // Custom resource: the returned disposer runs on unload. + ctx.effect(() => { + const connection = createConnection() + return () => connection.close() + }) +} +``` + +The framework tracks and disposes all of these operations: +- `ctx.on(event, handler)` — event listener +- `ctx.tools.register(tool)` — tool registration +- `ctx.llm.registerAdapter(names, adapter)` — LLM adapter registration +- `ctx.effect(() => cleanup)` — custom resource + +During unload, disposer invocation starts in reverse registration order, but multiple async disposers run concurrently and have no serial completion guarantee. Put order-dependent cleanup in one disposer returned from a single `ctx.effect()` and await its steps serially there. + +## Nested contexts + +`ctx.plugin()` creates a child Fiber that inherits the parent context but has an independent lifecycle: + +```ts ignore-check +export function apply(ctx: Context) { + // Register a child plugin. + ctx.plugin(childPlugin) + + // The child has its own Fiber and unloads with its parent. +} +``` + +## Dispose semantics + +To stop a plugin instance early: + +```ts ignore-check +const fiber = ctx.plugin(myPlugin) + +// Dispose it manually later. +fiber.dispose() +``` + +`dispose` guarantees: +1. All registrations owned by the plugin are removed. +2. Child plugins are recursively unloaded. +3. The returned promise resolves after all asynchronous cleanup finishes. + +## Hot replacement (HMR) + +With `@cordisjs/plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: + +1. Unload the old plugin and clean up its registrations. +2. Load the new code. +3. Run the new `apply`. + +Because plugin registrations clean themselves up, hot replacement does not retain registrations from the old instance. + +## Example lifecycle + +```ts ignore-check +export function apply(ctx: Context) { + console.log('plugin loading') + + ctx.effect(() => { + console.log('effect registered') + return () => console.log('effect cleaned up') + }) +} +``` + +Loading prints: +``` +plugin loading +effect registered +``` + +Unloading prints: +``` +effect cleaned up +``` + +## Next steps + +- [Services and dependencies](./service.md) — expose a capability to other plugins +- [Event system](./events.md) — communicate between plugins diff --git a/docs/user/zh-CN/develop/framework/index.md b/docs/user/develop/framework/index.zh.md similarity index 80% rename from docs/user/zh-CN/develop/framework/index.md rename to docs/user/develop/framework/index.zh.md index a3fdd502b5..4b9d6d22c8 100644 --- a/docs/user/zh-CN/develop/framework/index.md +++ b/docs/user/develop/framework/index.zh.md @@ -1,5 +1,7 @@ # 插件与生命周期 +[English](index.md) | 中文 + 深入了解 Cordis 插件模型和生命周期状态机。 ## Fiber 状态机 @@ -25,11 +27,11 @@ ACTIVE → UNLOADING → DISPOSED 声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪: -```typescript +```ts ignore-check export const inject = ['tools', 'llm'] export function apply(ctx: Context) { - // 到这里时,ctx.tools 和 ctx.llm 一定存在 + // ctx.tools and ctx.llm are ready here. } ``` @@ -39,12 +41,12 @@ export function apply(ctx: Context) { 通过 `ctx` 做的任何注册,在插件卸载时都会自动撤销: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 事件监听——卸载时自动移除 + // Event listener: removed automatically on unload. ctx.on('some-event', handler) - // 自定义资源——卸载时调用返回的函数 + // Custom resource: the returned disposer runs on unload. ctx.effect(() => { const connection = createConnection() return () => connection.close() @@ -58,18 +60,18 @@ export function apply(ctx: Context) { - `ctx.llm.registerAdapter(names, adapter)` — LLM 适配器注册 - `ctx.effect(() => cleanup)` — 自定义资源 -插件卸载时,这些注册按倒序逐个撤销。 +插件卸载时,处置器按注册顺序的反向发起,但多个异步处置器会并发执行,不保证逐个完成。存在顺序依赖的清理步骤必须放进同一个 `ctx.effect()` 返回的处置器中,由该处置器负责串行等待。 ## 嵌套上下文 `ctx.plugin()` 创建子 Fiber,它继承父上下文但有独立的生命周期: -```typescript +```ts ignore-check export function apply(ctx: Context) { - // 注册一个子插件 + // Register a child plugin. ctx.plugin(childPlugin) - // 子插件有自己的 Fiber,父卸载时子也卸载 + // The child has its own Fiber and unloads with its parent. } ``` @@ -77,10 +79,10 @@ export function apply(ctx: Context) { 当你需要提前终止一个插件实例: -```typescript +```ts ignore-check const fiber = ctx.plugin(myPlugin) -// 之后可以手动 dispose +// Dispose it manually later. fiber.dispose() ``` @@ -101,7 +103,7 @@ fiber.dispose() ## 实战:理解生命周期 -```typescript +```ts ignore-check export function apply(ctx: Context) { console.log('plugin loading') diff --git a/docs/user/develop/framework/service.i18n.yaml b/docs/user/develop/framework/service.i18n.yaml new file mode 100644 index 0000000000..f0deb18959 --- /dev/null +++ b/docs/user/develop/framework/service.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 +service.md: 1bf28cb3c7dfdfbd6d0babfa3b1688ac65eea01e +service.zh.md: 17785c056ab9a0a21974e6ed8bbe7f7de05fa00e diff --git a/docs/user/develop/framework/service.md b/docs/user/develop/framework/service.md new file mode 100644 index 0000000000..1bf28cb3c7 --- /dev/null +++ b/docs/user/develop/framework/service.md @@ -0,0 +1,148 @@ +# Services and dependencies + +English | [中文](service.zh.md) + +A service is a capability one plugin exposes to other plugins. `inject` declares the services a plugin requires. + +## What is a service? + +In Harness, `tools`, `llm`, and `agents` are services. Each is a named capability mounted on `ctx`: + +```ts ignore-check +ctx.tools // ToolRegistry service +ctx.llm // LLM service +ctx.agents // Agent service +``` + +Any plugin can provide a service for other plugins to consume. + +## Consume a service + +Declare `inject` to use an existing service: + +```ts ignore-check +export const inject = ['tools'] + +export function apply(ctx: Context) { + // ctx.tools exists and is ready here. + ctx.tools.register(/* ... */) +} +``` + +When `apply` runs, every service declared by `inject` is ready. If a service is not ready, the plugin waits instead of running. + +## Provide a service + +### Extend Service + +```ts +import { Service, type Context } from 'cordis' + +export default class MetricsService extends Service { + static inject = ['llm'] // A service may depend on other services. + + constructor(ctx: Context) { + super(ctx, 'metrics') // 'metrics' is the service name. + } + + // Public service method. + record(event: string, value: number) { + // ... + } +} +``` + +After loading this plugin, consumers access the service as `ctx.metrics`: + +```ts ignore-check +export const inject = ['metrics'] + +export function apply(ctx: Context) { + ctx.metrics.record('tool_call', 1) +} +``` + +### Declare its type + +Use TypeScript declaration merging to type `ctx.metrics`: + +```ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + metrics: MetricsService + } +} + +export default class MetricsService extends Service { + constructor(ctx: Context) { + super(ctx, 'metrics') + } + + record(event: string, value: number) { /* ... */ } +} +``` + +## Dependency behavior + +### Required and optional dependencies + +```ts ignore-check +// Required: the plugin does not load while the service is absent. +export const inject = ['tools'] + +// Optional: omit inject and query with ctx.get() at the use site. +export function apply(ctx: Context) { + const metrics = ctx.get('metrics') + metrics?.record('plugin_loaded', 1) +} +``` + +### When a service disappears + +If a required service disappears while the application is running, for example because its provider unloads: + +1. Dependent plugins dispose automatically. +2. They load again when the service returns. + +This prevents a plugin from calling a service that no longer exists. + +## Service isolation + +`cordis.yml` can isolate services so separate plugin groups see separate instances of the same service: + +```yaml +- id: group-a + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 5000 + - name: './src/plugin-a.ts' + +- id: group-b + name: '@cordisjs/plugin-group' + group: true + isolate: + bash: true + config: + - name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + - name: './src/plugin-b.ts' +``` + +`plugin-a` and `plugin-b` each see the Bash instance in their own group, with no cross-group effect. + +## Built-in Harness services + +The repository generates the service names, public methods, and source locations in the [service catalog](../../../cordis-catalog/services.md). Use that catalog and the service's TypeScript interface while developing a plugin; do not maintain a second static list. + +## Next steps + +- [Event system](./events.md) — communicate between plugins without tight coupling +- [Capability layering](../practice/) — use services as capability interfaces diff --git a/docs/user/zh-CN/develop/framework/service.md b/docs/user/develop/framework/service.zh.md similarity index 80% rename from docs/user/zh-CN/develop/framework/service.md rename to docs/user/develop/framework/service.zh.md index 19edf4a975..17785c056a 100644 --- a/docs/user/zh-CN/develop/framework/service.md +++ b/docs/user/develop/framework/service.zh.md @@ -1,15 +1,17 @@ # 服务与依赖 +[English](service.md) | 中文 + 服务 (Service) 是插件对外暴露能力的方式。依赖 (inject) 是插件声明自己需要哪些服务。 ## 什么是服务 在 Harness 中,`tools`、`llm`、`agents` 都是服务。服务是挂载在 `ctx` 上的命名能力: -```typescript -ctx.tools // ToolRegistry 服务 -ctx.llm // LLM 服务 -ctx.agents // Agent 服务 +```ts ignore-check +ctx.tools // ToolRegistry service +ctx.llm // LLM service +ctx.agents // Agent service ``` 任何插件都可以提供一个新服务,供其他插件使用。 @@ -18,11 +20,11 @@ ctx.agents // Agent 服务 声明 `inject` 来使用已有服务: -```typescript +```ts ignore-check export const inject = ['tools'] export function apply(ctx: Context) { - // ctx.tools 在这里一定存在且就绪 + // ctx.tools exists and is ready here. ctx.tools.register(/* ... */) } ``` @@ -33,17 +35,17 @@ export function apply(ctx: Context) { ### 使用 Service 基类 -```typescript +```ts import { Service, type Context } from 'cordis' export default class MetricsService extends Service { - static inject = ['llm'] // 本服务也可以依赖其他服务 + static inject = ['llm'] // A service may depend on other services. constructor(ctx: Context) { - super(ctx, 'metrics') // 'metrics' 是服务名 + super(ctx, 'metrics') // 'metrics' is the service name. } - // 服务的公开方法 + // Public service method. record(event: string, value: number) { // ... } @@ -52,7 +54,7 @@ export default class MetricsService extends Service { 加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它: -```typescript +```ts ignore-check export const inject = ['metrics'] export function apply(ctx: Context) { @@ -64,7 +66,7 @@ export function apply(ctx: Context) { 使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: -```typescript +```ts import { Service, type Context } from 'cordis' declare module 'cordis' { @@ -86,11 +88,11 @@ export default class MetricsService extends Service { ### 必选依赖 vs 可选依赖 -```typescript -// 必选:服务不存在时,插件不会加载 +```ts ignore-check +// Required: the plugin does not load while the service is absent. export const inject = ['tools'] -// 可选:不写入 inject,使用时通过 ctx.get() 查询 +// Optional: omit inject and query with ctx.get() at the use site. export function apply(ctx: Context) { const metrics = ctx.get('metrics') metrics?.record('plugin_loaded', 1) @@ -138,7 +140,7 @@ export function apply(ctx: Context) { ## Harness 内置服务 -服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 +服务名、公开方法和源码位置由仓库自动生成,见[服务目录](../../../cordis-catalog/services.md)。开发插件时应以该目录和服务接口的 TypeScript 类型为准,不要复制一份静态清单。 ## 下一步 diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml new file mode 100644 index 0000000000..d2478abf75 --- /dev/null +++ b/docs/user/develop/practice/index.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 +index.md: 0261b49b071167f7c2a33f78bbc1959cc6f1879f +index.zh.md: 5819344430fcbde31bf825e9815120983e44e3f6 diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md new file mode 100644 index 0000000000..0261b49b07 --- /dev/null +++ b/docs/user/develop/practice/index.md @@ -0,0 +1,158 @@ +# Three-layer capability design + +English | [中文](index.zh.md) + +When a capability is general enough to need replaceable implementations, such as Bash execution, Harness splits it into three packages: an **interface**, an **implementation**, and a **consumer**. Each layer can evolve or be replaced independently. + +## Bash example + +The Bash execution capability consists of: + +- **Interface** (`dsh-bash`) — defines Bash request and result shapes +- **Implementation** (`dsh-bash-local`) — executes commands on the local machine +- **Consumer** (`dsh-tool-bash`) — exposes the capability as a model-callable tool + +``` +┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│ +│ (interface) │ │ (implementation) │ │(consumer/tool)│ +└─────────────┘ └──────────────────┘ └──────────────┘ + ▲ │ + └────────────────────────────────────────────┘ + inject: ['bash'] +``` + +## Benefits of the split + +### Replace implementations + +One interface can have multiple implementations selected through `cordis.yml`: + +```yaml +# Local execution +- name: '@deepseek-ai/dsh-bash-local' + +# Or a future remote sandbox implementation +# - name: '@deepseek-ai/dsh-bash-remote' +# config: +# endpoint: 'https://sandbox.example.com' +``` + +The interface and tool remain unchanged while the implementation changes. + +### Evolve independently + +- The interface changes rarely after its contract stabilizes. +- Implementations can improve performance and security independently. +- Consumers can change how they present the capability to the model. + +### Decouple dependencies + +- The implementation depends on the interface. +- The consumer depends on the interface. +- The implementation and consumer **do not depend on each other**. + +## Built-in three-layer capabilities + +| Capability | Interface | Implementation | Consumer | +|------|-------------|------|---------------| +| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` | +| Filesystem | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` | +| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` | +| Subagent | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` | +| Compaction | `dsh-compact` | `dsh-compact-basic` | The implementation consumes agent-loop extension events | + +## Develop a three-layer capability + +### Step 1: define the interface + +```ts ignore-check +// packages/my-cap/my-cap/src/index.ts +import { Service, type Context } from 'cordis' + +declare module 'cordis' { + interface Context { + myCap: MyCapService + } +} + +export abstract class MyCapService extends Service { + constructor(ctx: Context) { + super(ctx, 'myCap') + } + + /** Execute the capability. */ + abstract execute(request: MyCapRequest): Promise +} + +export interface MyCapRequest { + input: string +} + +export interface MyCapResult { + output: string +} +``` + +### Step 2: write an implementation + +```ts ignore-check +// packages/my-cap/my-cap-local/src/index.ts +import type { Context } from 'cordis' +import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' + +class MyCapLocal extends MyCapService { + async execute(request: MyCapRequest): Promise { + // Concrete implementation. + return { output: request.input.toUpperCase() } + } +} + +export const name = 'my-cap-local' + +export function apply(ctx: Context) { + ctx.plugin(MyCapLocal) +} +``` + +### Step 3: write a consumer + +```ts ignore-check +// packages/my-cap/tool-my-cap/src/index.ts +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'tool-my-cap' +export const inject = ['tools', 'myCap'] + +export function apply(ctx: Context) { + ctx.tools.register(defineTool({ + name: 'my_cap', + description: 'Execute my capability.', + parameters: { + input: { type: 'string', required: true }, + }, + async execute(args) { + const result = await ctx.myCap.execute({ input: args.input }) + return [{ type: 'text', text: result.output }] + }, + })) +} +``` + +### Compose them in cordis.yml + +```yaml +- name: '@deepseek-ai/dsh-my-cap-local' +- name: '@deepseek-ai/dsh-tool-my-cap' +``` + +## Design points + +- **Do not split preemptively** — use three packages only when the capability needs replaceable implementations. A simple tool plugin does not. +- **The interface owns Request/Result types** — implementations and consumers depend only on the interface package. +- **Explicit > implicit** — resolve defaults in an explicit `resolve(request): Spec` step rather than hiding `?? default` expressions inside `run()`. + +## Next steps + +- [LLM adapter](./llm-adapter.md) — implement an LLM backend, a common capability interface extension diff --git a/docs/user/zh-CN/develop/practice/index.md b/docs/user/develop/practice/index.zh.md similarity index 94% rename from docs/user/zh-CN/develop/practice/index.md rename to docs/user/develop/practice/index.zh.md index bffa35f964..5819344430 100644 --- a/docs/user/zh-CN/develop/practice/index.md +++ b/docs/user/develop/practice/index.zh.md @@ -1,5 +1,7 @@ # 能力的三层拆分 +[English](index.md) | 中文 + 当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。 ## 以 Bash 为例 @@ -13,7 +15,7 @@ ``` ┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ │ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│ -│ (接口) │ │ (实现) │ │ (消费者/tool)│ +│ (interface) │ │ (implementation) │ │(consumer/tool)│ └─────────────┘ └──────────────────┘ └──────────────┘ ▲ │ └────────────────────────────────────────────┘ @@ -27,10 +29,10 @@ 同一个接口可以有多种实现。用户通过 `cordis.yml` 选择: ```yaml -# 本地执行 +# Local execution - name: '@deepseek-ai/dsh-bash-local' -# 或:远程沙箱执行(未来) +# Or a future remote sandbox implementation # - name: '@deepseek-ai/dsh-bash-remote' # config: # endpoint: 'https://sandbox.example.com' @@ -64,7 +66,7 @@ ### 第一步:定义接口 -```typescript +```ts ignore-check // packages/my-cap/my-cap/src/index.ts import { Service, type Context } from 'cordis' @@ -79,7 +81,7 @@ export abstract class MyCapService extends Service { super(ctx, 'myCap') } - /** 执行能力的核心方法 */ + /** Execute the capability. */ abstract execute(request: MyCapRequest): Promise } @@ -94,14 +96,14 @@ export interface MyCapResult { ### 第二步:编写实现 -```typescript +```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts import type { Context } from 'cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { async execute(request: MyCapRequest): Promise { - // 具体实现 + // Concrete implementation. return { output: request.input.toUpperCase() } } } @@ -115,7 +117,7 @@ export function apply(ctx: Context) { ### 第三步:编写消费者 (tool) -```typescript +```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml new file mode 100644 index 0000000000..84d622dde9 --- /dev/null +++ b/docs/user/develop/practice/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 +llm-adapter.md: 18e05ab79f7daf9f86fe1eb27bdd4440fb9107bc +llm-adapter.zh.md: f3c1ac70f7b4c11fb9f6dcb247be342fb358bd39 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md new file mode 100644 index 0000000000..18e05ab79f --- /dev/null +++ b/docs/user/develop/practice/llm-adapter.md @@ -0,0 +1,185 @@ +# LLM adapters + +English | [中文](llm-adapter.zh.md) + +This guide connects a new LLM provider to Harness. + +## Overview + +An LLM adapter extends `LlmAdapter` and implements `stream()`, translating Harness's provider-neutral request into a provider API call and translating the response back into Harness chunks. + +## Minimal implementation + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class MyAdapter extends LlmAdapter { + private apiKey: string + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async *stream(options: GenerateOptions): AsyncIterable { + // 1. Convert options.messages to the provider format. + // 2. Call the streaming API. + // 3. Convert the response into StreamChunk values. + } +} + +export interface Config { + apiKey: string + models: string[] +} + +export const Config: Schema = Schema.object({ + apiKey: Schema.string().required(), + models: Schema.array(Schema.string()).required(), +}) + +export const name = 'my-llm-adapter' +export const inject = ['llm'] + +export function apply(ctx: Context, config: Config) { + const adapter = new MyAdapter(config.apiKey) + ctx.llm.registerAdapter(config.models, adapter) +} +``` + +## StreamChunk protocol + +`stream()` yields chunks using this protocol: + +```ts +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' + +async function* exampleChunks(): AsyncIterable { + // 1. Start each content block with block-start. + yield { type: 'block-start', index: 0, blockType: 'text' } + + // 2. Stream text through text-delta. + yield { type: 'text-delta', index: 0, text: 'Hello' } + yield { type: 'text-delta', index: 0, text: ' world' } + + // 3. End each content block with block-end and the complete block. + yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, + } + + // 4. Tool-call block. + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, + id: CallId('call-123'), + name: 'bash', + argumentsDelta: '{"command":"ls"}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, + } + + // 5. Token usage. + yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + + // 6. Finish reason. + yield { type: 'finish', reason: { kind: 'stop' } } + // Alternatively, { kind: 'tool-calls' } requests tool execution. +} +``` + +### Key rules + +- Every `block-start` has a matching `block-end`. +- `index` increases from 0 and identifies content-block order. +- A `tool-call-delta` carries raw JSON text in `argumentsDelta`, either all at once or over multiple chunks. +- `finish` is the final chunk. +- Emit `usage` before `finish`. + +## GenerateOptions + +`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +## Register an adapter + +```ts ignore-check +ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) +``` + +The first argument lists the model names handled by the adapter. If `cordis.yml` selects `model: model-name-1`, the service routes that request to this adapter. + +## Use it from cordis.yml + +```yaml +- id: my-llm + name: './src/my-llm-adapter.ts' + config: + apiKey: !!js process.env.MY_API_KEY + models: + - my-model-v1 + - my-model-v2 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: my-model-v1 # References the model registered above. +``` + +## Reference implementations + +The repository contains complete implementations: + +- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format +- `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format +- `examples/echo-agent/src/mock-llm.ts` — minimal local teaching adapter + +Start with the mock adapter to study a complete chunk sequence without network behavior. + +## Error handling + +Adapters throw transport and protocol failures as `LlmError` values with stable codes. The agent loop preserves the error and code for diagnostics and policy; it does not convert an ordinary `Error` automatically. Every provider HTTP request must also merge `attributionHeaders()` and forward `options.signal`. + +```ts +import { + attributionHeaders, + LlmAdapter, + LlmError, + type GenerateOptions, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +class HttpAdapter extends LlmAdapter { + constructor(private readonly endpoint: string) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...attributionHeaders(), + }, + body: JSON.stringify({ model: options.model, messages: options.messages }), + ...options.signal ? { signal: options.signal } : {}, + }) + if (!response.ok) { + throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status) + } + // A real adapter parses the response and emits the complete chunk sequence. + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +``` diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md new file mode 100644 index 0000000000..f3c1ac70f7 --- /dev/null +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -0,0 +1,185 @@ +# LLM 适配器 + +[English](llm-adapter.md) | 中文 + +本文介绍如何为 Harness 接入一个新的 LLM 提供方。 + +## 概述 + +LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 + +## 最小实现 + +```ts +import type { Context } from 'cordis' +import Schema from 'schemastery' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +class MyAdapter extends LlmAdapter { + private apiKey: string + + constructor(apiKey: string) { + super() + this.apiKey = apiKey + } + + async *stream(options: GenerateOptions): AsyncIterable { + // 1. Convert options.messages to the provider format. + // 2. Call the streaming API. + // 3. Convert the response into StreamChunk values. + } +} + +export interface Config { + apiKey: string + models: string[] +} + +export const Config: Schema = Schema.object({ + apiKey: Schema.string().required(), + models: Schema.array(Schema.string()).required(), +}) + +export const name = 'my-llm-adapter' +export const inject = ['llm'] + +export function apply(ctx: Context, config: Config) { + const adapter = new MyAdapter(config.apiKey) + ctx.llm.registerAdapter(config.models, adapter) +} +``` + +## StreamChunk 协议 + +`stream()` 必须按以下协议 yield chunk: + +```ts +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' + +async function* exampleChunks(): AsyncIterable { + // 1. Start each content block with block-start. + yield { type: 'block-start', index: 0, blockType: 'text' } + + // 2. Stream text through text-delta. + yield { type: 'text-delta', index: 0, text: 'Hello' } + yield { type: 'text-delta', index: 0, text: ' world' } + + // 3. End each content block with block-end and the complete block. + yield { + type: 'block-end', + index: 0, + block: { type: 'text', text: 'Hello world' }, + } + + // 4. Tool-call block. + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, + id: CallId('call-123'), + name: 'bash', + argumentsDelta: '{"command":"ls"}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: CallId('call-123'), + name: 'bash', + arguments: '{"command":"ls"}', + }, + } + + // 5. Token usage. + yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } + + // 6. Finish reason. + yield { type: 'finish', reason: { kind: 'stop' } } + // Alternatively, { kind: 'tool-calls' } requests tool execution. +} +``` + +### 关键规则 + +- 每个 `block-start` 必须有对应的 `block-end` +- `index` 从 0 递增,标识内容块顺序 +- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) +- `finish` 必须是最后一个 chunk +- `usage` 在 `finish` 之前 yield + +## GenerateOptions + +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +## 注册适配器 + +```ts ignore-check +ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) +``` + +第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 + +## 在 cordis.yml 中使用 + +```yaml +- id: my-llm + name: './src/my-llm-adapter.ts' + config: + apiKey: !!js process.env.MY_API_KEY + models: + - my-model-v1 + - my-model-v2 + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: my-model-v1 # References the model registered above. +``` + +## 实战参考 + +仓库中有两个完整实现可供参考: + +- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) +- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) + +mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 + +## 错误处理 + +适配器应将传输和协议故障作为带稳定 code 的 `LlmError` 抛出;agent loop 会保留该错误及其 code,供诊断和策略使用。不要依赖普通 `Error` 被自动转换。每个提供方 HTTP 请求还必须合并 `attributionHeaders()`,并传递 `options.signal`。 + +```ts +import { + attributionHeaders, + LlmAdapter, + LlmError, + type GenerateOptions, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +class HttpAdapter extends LlmAdapter { + constructor(private readonly endpoint: string) { + super() + } + + async *stream(options: GenerateOptions): AsyncIterable { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...attributionHeaders(), + }, + body: JSON.stringify({ model: options.model, messages: options.messages }), + ...options.signal ? { signal: options.signal } : {}, + }) + if (!response.ok) { + throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR', response.status) + } + // A real adapter parses the response and emits the complete chunk sequence. + yield { type: 'finish', reason: { kind: 'stop' } } + } +} +``` diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml new file mode 100644 index 0000000000..c917b46a3e --- /dev/null +++ b/docs/user/guide/config.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 +config.md: 0cad49d9bd82813da230a44d526190d4e0b6a730 +config.zh.md: ee1ea9a0c1d9010be8c1a3984acdcbe870e30584 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md new file mode 100644 index 0000000000..0cad49d9bd --- /dev/null +++ b/docs/user/guide/config.md @@ -0,0 +1,59 @@ +# Configuration + +English | [中文](config.zh.md) + +Harness uses `cordis.yml` to describe which plugins an agent loads and the configuration passed to each one. The file composes capabilities; the generated configuration catalog records the fields and defaults each package actually supports, avoiding a second hand-maintained reference. + +## Start from a real configuration + +The repository examples are runnable configurations and the most reliable starting points for a new project: + +- [echo-agent](../../../examples/echo-agent/cordis.yml) uses a local mock model and needs no API key. +- [coding-agent](../../../examples/coding-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, and workflows. +- [acp-agent](../../../examples/acp-agent/cordis.yml) connects to editor clients over ACP. + +A minimal configuration is a list of plugin entries: + +```yaml +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## Plugin entries + +`name` identifies an npm package or a local module relative to `cordis.yml`; `id` gives the plugin instance a stable identity; and `config` supplies plugin-specific configuration. Set `disabled: true` to skip an entry temporarily. + +```yaml +- id: local-tool + name: './src/my-tool.ts' + disabled: false + config: + toolName: my_tool +``` + +Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. + +## JavaScript values and environment variables + +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. + +```yaml +config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + cwd: !!js process.cwd() +``` + +The tag is `!!js`, not `!js`. + +## Exact configuration reference + +The generated [plugin configuration catalog](../../config-catalog.md) lists every current field, type, and default. For composition concepts, continue to the [architecture](../../architecture.md) and [capability interfaces](../../capability-seams.md). To create a configuration, copy the closest entry from the [examples overview](../../../examples/README.md) and adapt it. diff --git a/docs/user/zh-CN/guide/config.md b/docs/user/guide/config.zh.md similarity index 71% rename from docs/user/zh-CN/guide/config.md rename to docs/user/guide/config.zh.md index 5e13b69d9f..ee1ea9a0c1 100644 --- a/docs/user/zh-CN/guide/config.md +++ b/docs/user/guide/config.zh.md @@ -1,14 +1,16 @@ # 配置文件 +[English](config.md) | 中文 + Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的参数。配置文件负责组合能力;每个包真正支持的字段和默认值由源码生成的配置目录负责记录,避免两份手写表格逐渐不一致。 ## 从真实配置开始 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [echo-agent](../../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 -- [coding-agent](../../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 -- [acp-agent](../../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 +- [echo-agent](../../../examples/echo-agent/cordis.yml) 使用本地 mock 模型,不需要 API key。 +- [coding-agent](../../../examples/coding-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理和工作流。 +- [acp-agent](../../../examples/acp-agent/cordis.yml) 通过 ACP 接入编辑器客户端。 最小配置由一组插件条目组成: @@ -54,4 +56,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../../architecture.md)和[能力接口](../../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml new file mode 100644 index 0000000000..12ccac1bcb --- /dev/null +++ b/docs/user/guide/index.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 +index.md: 4daf7fdc76b38b5d10cc8b4726d9b6239a72933f +index.zh.md: 1f41078822009234cbaf513aaa8dbc01c4b5d879 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md new file mode 100644 index 0000000000..4daf7fdc76 --- /dev/null +++ b/docs/user/guide/index.md @@ -0,0 +1,49 @@ +# Introduction + +English | [中文](index.zh.md) + +DeepSeek Harness is a **plugin-based agent development framework** built on the [Cordis](https://github.com/cordiverse/cordis) microkernel. Its central idea is simple: **everything is a plugin**. + +## What it is + +Harness implements every capability an AI agent needs—including LLM calls, tool execution, session management, and subtask delegation—as a composable plugin. A `cordis.yml` file declares which plugins to load and how to configure them, assembling a complete agent. + +```yaml +# Select the LLM backend +- name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + +# Select the application template +- name: '@deepseek-ai/dsh-stdio-agent' + config: + model: deepseek-v4-flash +``` + +## Who it is for + +### Application users + +To run an existing agent application, such as a coding assistant or conversational agent: + +1. Copy an example template. +2. Add an API key. +3. Run it. + +No code is required. See the [quick start](./quickstart.md). + +### Plugin developers + +To add a custom tool, a new LLM adapter, or another execution backend, write a plugin. Harness provides explicit extension interfaces and a type-safe development experience. See [development](../develop/basic/). + +## Core features + +- **Configuration only** — `cordis.yml` selects the capability set; changing a model or adding a tool is a configuration edit. +- **Hot replacement (HMR)** — edit plugin code during development without restarting the process. + +## Technology + +- **Runtime**: Node.js ^22.19 or >= 24 +- **Language**: TypeScript (ESM) +- **Framework**: Cordis +- **Package manager**: pnpm workspaces (the repository pins pnpm 11) diff --git a/docs/user/zh-CN/guide/index.md b/docs/user/guide/index.zh.md similarity index 94% rename from docs/user/zh-CN/guide/index.md rename to docs/user/guide/index.zh.md index 8c7f7e603a..1f41078822 100644 --- a/docs/user/zh-CN/guide/index.md +++ b/docs/user/guide/index.zh.md @@ -1,5 +1,7 @@ # 介绍 +[English](index.md) | 中文 + DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**。 ## 它是什么 @@ -7,12 +9,12 @@ DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis]( Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 Agent。 ```yaml -# 选择 LLM 后端 +# Select the LLM backend - name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# 选择应用模板 +# Select the application template - name: '@deepseek-ai/dsh-stdio-agent' config: model: deepseek-v4-flash diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml new file mode 100644 index 0000000000..310dfaa62f --- /dev/null +++ b/docs/user/guide/quickstart.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 +quickstart.md: c2932cf5a5f49c3e3ddb887866b421c6e6cad7a1 +quickstart.zh.md: 54ddb50be31203e98d4cd75d0b8851f8f0e14cc5 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md new file mode 100644 index 0000000000..c2932cf5a5 --- /dev/null +++ b/docs/user/guide/quickstart.md @@ -0,0 +1,99 @@ +# Quick start + +English | [中文](quickstart.zh.md) + +This guide gets an agent running in five minutes. + +## Prerequisites + +- [Node.js](https://nodejs.org/) ^22.19 or >= 24 +- [pnpm](https://pnpm.io/) 11 (use Corepack to select the repository-pinned version) + +```sh +# Check versions +node -v # v22.19.x, or v24.x and newer +corepack enable +pnpm -v # 11.x +``` + +## Step 1: run echo-agent + +echo-agent needs no API key and runs after dependencies are installed. + +```sh +# Clone the repository +git clone https://github.com/deepseek-harness/deepseek-harness.git +cd deepseek-harness + +# Install dependencies +pnpm install + +# Start echo-agent +pnpm run demo:echo +``` + +The process prints: + +``` +echo-agent ready. Type a message ("echo " triggers the tool). +> +``` + +Enter: + +``` +> echo hello world +``` + +The model issues a tool call, and the echo tool returns the text in uppercase: + +``` +[tool call] echo({"text":"hello world"}) +[tool result] ECHO: HELLO WORLD +``` + +Your local environment is ready. + +## Step 2: use a real model + +Next, connect a real DeepSeek model and run the complete command-line agent. + +### Get an API key + +Get an API key from [DeepSeek Platform](https://platform.deepseek.com/). + +### Configure the environment + +Create a gitignored `.env` file in the repository root: + +```sh +DEEPSEEK_API_KEY=sk-your-key-here +``` + +### Start coding-agent + +```sh +pnpm run demo:repl +``` + +``` +agent REPL ready. Give it a coding task. +> +``` + +This is a complete coding assistant that can read and write files, run commands, and delegate subtasks. + +Try a task: + +``` +> Create hello.js in the current directory, print "Hello from Harness!", and run it +``` + +## What happened + +echo-agent and coding-agent use the same application framework (`@deepseek-ai/dsh-stdio-agent`). Their `cordis.yml` files select different plugins and configuration. Custom agents use the same composition model. + +## Next steps + +- [Configuration](./config.md) — understand the `cordis.yml` format +- [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/zh-CN/guide/quickstart.md b/docs/user/guide/quickstart.zh.md similarity index 88% rename from docs/user/zh-CN/guide/quickstart.md rename to docs/user/guide/quickstart.zh.md index 7ca4b19332..54ddb50be3 100644 --- a/docs/user/zh-CN/guide/quickstart.md +++ b/docs/user/guide/quickstart.zh.md @@ -1,5 +1,7 @@ # 快速开始 +[English](quickstart.md) | 中文 + 本指南带你在 5 分钟内跑起一个 Agent。 ## 环境准备 @@ -8,8 +10,8 @@ - [pnpm](https://pnpm.io/) 11(建议通过 Corepack 使用仓库固定的版本) ```sh -# 确认版本 -node -v # v22.19.x,或 v24.x 及更高版本 +# Check versions +node -v # v22.19.x, or v24.x and newer corepack enable pnpm -v # 11.x ``` @@ -19,14 +21,14 @@ pnpm -v # 11.x echo-agent 不需要 API key,装好依赖就能跑。 ```sh -# 克隆仓库 +# Clone the repository git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness -# 安装依赖 +# Install dependencies pnpm install -# 启动 echo-agent +# Start echo-agent pnpm run demo:echo ``` @@ -84,7 +86,7 @@ agent REPL ready. Give it a coding task. 试着给它一个任务: ``` -> 在当前目录创建一个 hello.js,内容是打印 "Hello from Harness!",然后运行它 +> Create hello.js in the current directory, print "Hello from Harness!", and run it ``` ## 回头看 diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml new file mode 100644 index 0000000000..b3fc8da2d2 --- /dev/null +++ b/docs/user/index.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 +index.md: e9a1f03785c7472c47550ec59ea0165d28d3d9a6 +index.zh.md: 907f1452c9ff50d619989c18dcf2727addb2573d diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 0000000000..e9a1f03785 --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,25 @@ +--- +layout: home +hero: + name: DeepSeek Harness + text: Plugin-based agent development framework + tagline: Built on the Cordis microkernel; everything is a plugin + actions: + - theme: brand + text: Quick start + link: /en/guide/quickstart + - theme: alt + text: Develop plugins + link: /en/develop/basic/ +features: + - title: Plugin architecture + details: Built on the Cordis plugin system. Every capability is registered by a plugin, takes effect when loaded, and is reverted when unloaded. + - title: Configuration as composition + details: One cordis.yml determines the agent's complete capability set. Change a model or add a tool by editing configuration. + - title: Ready to use + details: Includes LLM calls, file access, Bash execution, subagent delegation, and the rest of the core toolchain. Copy a template to get started. +--- + +# DeepSeek Harness + +English | [中文](index.zh.md) diff --git a/docs/user/zh-CN/index.md b/docs/user/index.zh.md similarity index 93% rename from docs/user/zh-CN/index.md rename to docs/user/index.zh.md index 1c495125c6..907f1452c9 100644 --- a/docs/user/zh-CN/index.md +++ b/docs/user/index.zh.md @@ -19,3 +19,7 @@ features: - title: 开箱即用 details: 内置 LLM 调用、文件读写、Bash 执行、子代理委派等完整工具链,复制模板即可运行。 --- + +# DeepSeek Harness + +[English](index.md) | 中文 diff --git a/docs/user/zh-CN/develop/practice/llm-adapter.md b/docs/user/zh-CN/develop/practice/llm-adapter.md deleted file mode 100644 index 0b0ae3cff0..0000000000 --- a/docs/user/zh-CN/develop/practice/llm-adapter.md +++ /dev/null @@ -1,174 +0,0 @@ -# LLM 适配器 - -本文介绍如何为 Harness 接入一个新的 LLM 提供方。 - -## 概述 - -LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。 - -## 最小实现 - -```typescript -import type { Context } from 'cordis' -import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' - -class MyAdapter extends LlmAdapter { - private apiKey: string - - constructor(apiKey: string) { - super() - this.apiKey = apiKey - } - - async *stream(options: GenerateOptions): AsyncIterable { - // 1. 将 options.messages 转换为你的 API 格式 - // 2. 调用 API(流式) - // 3. 将 API 响应转换为 StreamChunk 序列 - } -} - -export interface Config { - apiKey: string - models: string[] -} - -export const name = 'my-llm-adapter' -export const inject = ['llm'] - -export function apply(ctx: Context, config: Config) { - const adapter = new MyAdapter(config.apiKey) - ctx.llm.registerAdapter(config.models, adapter) -} -``` - -## StreamChunk 协议 - -`stream()` 必须按以下协议 yield chunk: - -```typescript -// 1. 每个内容块以 block-start 开始 -yield { type: 'block-start', index: 0, blockType: 'text' } - -// 2. 文本块使用 text-delta -yield { type: 'text-delta', index: 0, text: 'Hello' } -yield { type: 'text-delta', index: 0, text: ' world' } - -// 3. 每个内容块以 block-end 结束(携带完整 block) -yield { - type: 'block-end', - index: 0, - block: { type: 'text', text: 'Hello world' }, -} - -// 4. Tool call 块 -yield { type: 'block-start', index: 1, blockType: 'tool-call' } -yield { - type: 'tool-call-delta', - index: 1, - id: CallId('call-123'), - name: 'bash', - argumentsDelta: '{"command":"ls"}', -} -yield { - type: 'block-end', - index: 1, - block: { - type: 'tool-call', - id: CallId('call-123'), - name: 'bash', - arguments: '{"command":"ls"}', - }, -} - -// 5. Token 用量 -yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } - -// 6. 结束原因 -yield { type: 'finish', reason: { kind: 'stop' } } -// 或: { kind: 'tool-calls' } 表示模型想调用 tool -``` - -### 关键规则 - -- 每个 `block-start` 必须有对应的 `block-end` -- `index` 从 0 递增,标识内容块顺序 -- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次) -- `finish` 必须是最后一个 chunk -- `usage` 在 `finish` 之前 yield - -## GenerateOptions - -`stream()` 接收的请求包含: - -```typescript -interface GenerateOptions { - /** 模型名 */ - model: string - /** 对话历史 */ - messages: Message[] - /** 可用的 tool 列表 */ - tools?: ToolSpec[] - /** 系统提示词 */ - system?: string - /** 最大输出 token */ - maxTokens?: number - /** 温度 */ - temperature?: number - /** 取消或卸载时中止进行中的请求 */ - signal?: AbortSignal -} -``` - -你的适配器需要将这些映射到具体 API 的参数。 - -## 注册适配器 - -```typescript -ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) -``` - -第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。 - -## 在 cordis.yml 中使用 - -```yaml -- id: my-llm - name: './src/my-llm-adapter.ts' - config: - apiKey: !!js process.env.MY_API_KEY - models: - - my-model-v1 - - my-model-v2 - -- id: stdio-agent - name: '@deepseek-ai/dsh-stdio-agent' - config: - model: my-model-v1 # 引用上面注册的模型名 -``` - -## 实战参考 - -仓库中有两个完整实现可供参考: - -- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) -- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) -- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用) - -mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。 - -## 错误处理 - -适配器中的异常会被 agent-loop 捕获并转化为 `LlmError`,告知上层。不需要在 `stream()` 内部做错误恢复——让异常冒泡即可。 - -```typescript -async *stream(options: GenerateOptions): AsyncIterable { - const response = await fetch(this.endpoint, { - // ...method、headers 和 body - signal: options.signal, - }) - if (!response.ok) { - throw new Error(`API error: ${response.status}`) - } - // ... 正常流式处理 -} -``` diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 9a6162576d..19417d5d99 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -20,6 +20,7 @@ function fixture(): { root: string; pages: DocsPage[] } { mkdirSync(join(root, 'packages'), { recursive: true }) writeFileSync(join(root, 'docs/a.md'), '# A\n') writeFileSync(join(root, 'docs/b.md'), '# B\n') + writeFileSync(join(root, 'docs/x(y).md'), '# Parentheses\n') writeFileSync(join(root, 'packages/tool.ts'), 'one\ntwo\n') writeFileSync(join(root, 'packages/logo.svg'), '\n') return { @@ -88,6 +89,46 @@ describe('rewriteMarkdown', () => { })).toBe(source) }) + it('replaces the destination token without changing repeated titles or escapes', () => { + const { root, pages } = fixture() + const source = '[title](b.md "b.md") [escaped](x\\(y\\).md)\n' + expect(rewriteMarkdown(source, { + locale: 'en', + sourcePath: 'docs/a.md', + route: 'en/a.md', + pages, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe( + '[title](./reference/b.md "b.md") ' + + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n', + ) + }) + + it('routes a pair switcher across locales while ordinary links stay in locale', () => { + const { root, pages } = fixture() + writeFileSync(join(root, 'docs/a.zh.md'), '# A\n') + const paired = pages.filter(page => page.source !== 'docs/a.md') + paired.push( + { + locale: 'root', contentLocale: 'zh-CN', source: 'docs/a.zh.md', sourceAliases: ['docs/a.md'], + route: 'guide/a.md', label: 'A', sidebar: 'zh-guide', section: 'Test', order: 1, + }, + { + locale: 'en', contentLocale: 'en-US', source: 'docs/a.md', sourceAliases: ['docs/a.zh.md'], + route: 'en/guide/a.md', label: 'A', sidebar: 'en-guide', section: 'Test', order: 1, + }, + ) + expect(rewriteMarkdown('[English](a.md) [B](b.md)\n', { + locale: 'root', + sourcePath: 'docs/a.zh.md', + route: 'guide/a.md', + pages: paired, + repoRoot: root, + repositoryRef: 'abc123', + })).toBe('[English](../en/guide/a.md) [B](../reference-root/b.md)\n') + }) + it('fails loud when a relative target is missing', () => { const { root, pages } = fixture() expect(() => rewriteMarkdown('[missing](missing.md)\n', { @@ -102,14 +143,21 @@ describe('rewriteMarkdown', () => { }) describe('docsPages locale routes', () => { - it('publishes the same canonical source at every corresponding locale route', () => { + it('publishes every route in both locales and selects paired user sources', () => { const byRoute = new Map(docsPages.map(page => [page.route, page])) for (const page of docsPages.filter(page => page.locale === 'root')) { const counterpart = byRoute.get(`en/${page.route}`) expect(counterpart, page.route).toBeDefined() expect(counterpart?.locale).toBe('en') - expect(counterpart?.source).toBe(page.source) - expect(counterpart?.contentLocale).toBe(page.contentLocale) + if (page.source.startsWith('docs/user/')) { + expect(page.source).toMatch(/\.zh\.md$/) + expect(page.contentLocale).toBe('zh-CN') + expect(counterpart?.source).toBe(page.source.replace(/\.zh\.md$/, '.md')) + expect(counterpart?.contentLocale).toBe('en-US') + } else { + expect(counterpart?.source).toBe(page.source) + expect(counterpart?.contentLocale).toBe(page.contentLocale) + } } }) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 7ef35a6d28..5c43e6f46a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -23,6 +23,13 @@ interface Replacement { value: string } +interface DestinationRange { + start: number + end: number +} + +type RewritableNode = Extract + /** Inputs for rewriting one canonical Markdown page. */ export interface RewriteMarkdownOptions { locale: DocsLocale @@ -44,6 +51,75 @@ function isExternalOrSiteAbsolute(url: string): boolean { || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) } +function skipWhitespace(source: string, start: number): number { + let index = start + while (/\s/.test(source[index] ?? '')) index += 1 + return index +} + +function labelEnd(source: string): number { + const first = source.indexOf('[') + if (first === -1) return -1 + let depth = 0 + for (let index = first; index < source.length; index += 1) { + const char = source[index] + if (char === '\\') { + index += 1 + } else if (char === '[') { + depth += 1 + } else if (char === ']') { + depth -= 1 + if (depth === 0) return index + } + } + return -1 +} + +function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange { + const endOfLabel = labelEnd(rawNode) + if (endOfLabel === -1) { + throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`) + } + + let start: number + if (type === 'definition') { + const colon = rawNode.indexOf(':', endOfLabel + 1) + if (colon === -1) { + throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`) + } + start = skipWhitespace(rawNode, colon + 1) + } else { + if (rawNode[endOfLabel + 1] !== '(') { + throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`) + } + start = skipWhitespace(rawNode, endOfLabel + 2) + } + + if (rawNode[start] === '<') { + for (let index = start + 1; index < rawNode.length; index += 1) { + if (rawNode[index] === '\\') index += 1 + else if (rawNode[index] === '>') return { start: start + 1, end: index } + } + throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`) + } + + let depth = 0 + for (let index = start; index < rawNode.length; index += 1) { + const char = rawNode[index] + if (char === '\\') { + index += 1 + } else if (char === '(') { + depth += 1 + } else if (char === ')') { + if (depth === 0) return { start, end: index } + depth -= 1 + } else if (/\s/.test(char ?? '') && depth === 0) { + return { start, end: index } + } + } + return { start, end: rawNode.length } +} + function splitTarget(url: string): { path: string; suffix: string } { const boundary = url.search(/[?#]/) if (boundary === -1) return { path: url, suffix: '' } @@ -78,6 +154,12 @@ function sourceMap(pages: DocsPage[]): Map> { return map } +function counterpartSource(source: string): string { + return source.endsWith('.zh.md') + ? source.replace(/\.zh\.md$/, '.md') + : source.replace(/\.md$/, '.zh.md') +} + function resolveRepositoryTarget(sourceAbs: string, rawPath: string, repoRoot: string): { absPath: string; line?: number } { const decoded = decodePath(rawPath) let absPath = resolve(dirname(sourceAbs), decoded) @@ -129,13 +211,17 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) const replacements: Replacement[] = [] - const rewrite = (node: Nodes & { url: string }): void => { + const rewrite = (node: RewritableNode): void => { if (isExternalOrSiteAbsolute(node.url)) return const { path, suffix } = splitTarget(node.url) if (path === '') return const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot) const targetPath = repoPath(absPath, options.repoRoot) - const page = published.get(targetPath)?.get(options.locale) + const isLanguageSwitcher = targetPath === counterpartSource(options.sourcePath) + const targetLocale: DocsLocale = isLanguageSwitcher + ? options.locale === 'root' ? 'en' : 'root' + : options.locale + const page = published.get(targetPath)?.get(targetLocale) const nextUrl = page === undefined ? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image') : routeTarget(options.route, page.route, suffix) @@ -146,13 +232,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`) } const rawNode = source.slice(start, end) - const urlOffset = rawNode.lastIndexOf(node.url) - if (urlOffset === -1) { - throw new Error(`project-doc-site: cannot locate raw target ${JSON.stringify(node.url)} in ${JSON.stringify(rawNode)}.`) - } + const rawDestination = destinationRange(rawNode, node.type) replacements.push({ - start: start + urlOffset, - end: start + urlOffset + node.url.length, + start: start + rawDestination.start, + end: start + rawDestination.end, value: nextUrl, }) } diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 35a957e57d..c79d5c1492 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -11,6 +11,18 @@ "docs/development.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", + "docs/user/develop/basic/config.md", + "docs/user/develop/basic/index.md", + "docs/user/develop/basic/tool.md", + "docs/user/develop/framework/events.md", + "docs/user/develop/framework/index.md", + "docs/user/develop/framework/service.md", + "docs/user/develop/practice/index.md", + "docs/user/develop/practice/llm-adapter.md", + "docs/user/guide/config.md", + "docs/user/guide/index.md", + "docs/user/guide/quickstart.md", + "docs/user/index.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", "python/README.md", diff --git a/website/docs.ts b/website/docs.ts index 16d53e5b8a..0e6897afe3 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -42,63 +42,93 @@ export interface DocsPage { } interface MirroredPage { - source: string + source: string | Record route: string - contentLocale: DocsPage['contentLocale'] + contentLocale: DocsPage['contentLocale'] | Record label: Record sidebar: Record section: Record order: number + sourceAliases?: string[] | Partial> +} + +type PairedPage = Omit & { + /** English side of a sibling `foo.md` / `foo.zh.md` pair. */ + source: string + /** Language-neutral repository aliases, such as the directory of an index page. */ sourceAliases?: string[] } -function mirroredPages(pages: MirroredPage[]): DocsPage[] { - return pages.flatMap(page => (['root', 'en'] as const).map(locale => ({ - locale, - contentLocale: page.contentLocale, - source: page.source, - route: locale === 'root' ? page.route : `en/${page.route}`, - label: page.label[locale], - sidebar: page.sidebar[locale], - section: page.section[locale], - order: page.order, - ...(page.sourceAliases === undefined ? {} : { sourceAliases: page.sourceAliases }), - }))) +function localized(value: T | Record, locale: DocsLocale): T { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record)[locale] + : value } -const homeAndGuide = mirroredPages([ +function mirroredPages(pages: MirroredPage[]): DocsPage[] { + return pages.flatMap(page => (['root', 'en'] as const).map((locale) => { + const aliases = page.sourceAliases === undefined + ? undefined + : Array.isArray(page.sourceAliases) ? page.sourceAliases : page.sourceAliases[locale] + return { + locale, + contentLocale: localized(page.contentLocale, locale), + source: localized(page.source, locale), + route: locale === 'root' ? page.route : `en/${page.route}`, + label: page.label[locale], + sidebar: page.sidebar[locale], + section: page.section[locale], + order: page.order, + ...(aliases === undefined ? {} : { sourceAliases: aliases }), + } + })) +} + +function pairedPages(pages: PairedPage[]): DocsPage[] { + return mirroredPages(pages.map((page) => { + const chineseSource = page.source.replace(/\.md$/, '.zh.md') + const sharedAliases = page.sourceAliases ?? [] + return { + ...page, + source: { root: chineseSource, en: page.source }, + contentLocale: { root: 'zh-CN', en: 'en-US' }, + sourceAliases: { + root: [...sharedAliases, page.source], + en: [...sharedAliases, chineseSource], + }, + } + })) +} + +const homeAndGuide = pairedPages([ { - source: 'docs/user/zh-CN/index.md', + source: 'docs/user/index.md', route: 'index.md', - contentLocale: 'zh-CN', label: { root: 'DeepSeek Harness', en: 'DeepSeek Harness' }, sidebar: { root: null, en: null }, section: { root: '首页', en: 'Home' }, order: 0, }, { - source: 'docs/user/zh-CN/guide/index.md', + source: 'docs/user/guide/index.md', route: 'guide/index.md', - contentLocale: 'zh-CN', label: { root: '介绍', en: 'Introduction' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 1, - sourceAliases: ['docs/user/zh-CN/guide'], + sourceAliases: ['docs/user/guide'], }, { - source: 'docs/user/zh-CN/guide/quickstart.md', + source: 'docs/user/guide/quickstart.md', route: 'guide/quickstart.md', - contentLocale: 'zh-CN', label: { root: '快速开始', en: 'Quick start' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, order: 2, }, { - source: 'docs/user/zh-CN/guide/config.md', + source: 'docs/user/guide/config.md', route: 'guide/config.md', - contentLocale: 'zh-CN', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, @@ -106,77 +136,69 @@ const homeAndGuide = mirroredPages([ }, ]) -const develop = mirroredPages([ +const develop = pairedPages([ { - source: 'docs/user/zh-CN/develop/basic/index.md', + source: 'docs/user/develop/basic/index.md', route: 'develop/basic/index.md', - contentLocale: 'zh-CN', label: { root: '第一个插件', en: 'First plugin' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/basic'], + sourceAliases: ['docs/user/develop/basic'], }, { - source: 'docs/user/zh-CN/develop/basic/tool.md', + source: 'docs/user/develop/basic/tool.md', route: 'develop/basic/tool.md', - contentLocale: 'zh-CN', label: { root: '开发一个 Tool', en: 'Build a tool' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 2, }, { - source: 'docs/user/zh-CN/develop/basic/config.md', + source: 'docs/user/develop/basic/config.md', route: 'develop/basic/config.md', - contentLocale: 'zh-CN', label: { root: '插件配置', en: 'Plugin configuration' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 3, }, { - source: 'docs/user/zh-CN/develop/framework/index.md', + source: 'docs/user/develop/framework/index.md', route: 'develop/framework/index.md', - contentLocale: 'zh-CN', label: { root: '插件与生命周期', en: 'Plugin lifecycle' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/framework'], + sourceAliases: ['docs/user/develop/framework'], }, { - source: 'docs/user/zh-CN/develop/framework/service.md', + source: 'docs/user/develop/framework/service.md', route: 'develop/framework/service.md', - contentLocale: 'zh-CN', label: { root: '服务与依赖', en: 'Services and dependencies' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 2, }, { - source: 'docs/user/zh-CN/develop/framework/events.md', + source: 'docs/user/develop/framework/events.md', route: 'develop/framework/events.md', - contentLocale: 'zh-CN', label: { root: '事件系统', en: 'Event system' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '框架能力', en: 'Framework' }, order: 3, }, { - source: 'docs/user/zh-CN/develop/practice/index.md', + source: 'docs/user/develop/practice/index.md', route: 'develop/practice/index.md', - contentLocale: 'zh-CN', label: { root: '能力的三层拆分', en: 'Capability layering' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '实战', en: 'Practice' }, order: 1, - sourceAliases: ['docs/user/zh-CN/develop/practice'], + sourceAliases: ['docs/user/develop/practice'], }, { - source: 'docs/user/zh-CN/develop/practice/llm-adapter.md', + source: 'docs/user/develop/practice/llm-adapter.md', route: 'develop/practice/llm-adapter.md', - contentLocale: 'zh-CN', label: { root: 'LLM 适配器', en: 'LLM adapter' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '实战', en: 'Practice' }, From a425aff586a5721a4ecd39dbc1e5be183794b092 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:52:35 +0800 Subject: [PATCH 05/28] docs(rfc): propose one-send-one-turn simplification --- docs/rfc/INDEX.md | 1 + .../2026-07-17-one-send-one-turn.i18n.yaml | 6 ++++ .../2026-07-17-one-send-one-turn.md | 36 +++++++++++++++++++ .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..c0992414af 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,6 +22,7 @@ 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 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml new file mode 100644 index 0000000000..d485306ae6 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.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-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c +2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..2542b1038b --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,36 @@ +# RFC: Give each ordinary send its own turn + +Status: proposed + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. + +A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. + +## Proposal + +The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. + +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. + +Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. + +## Acceptance criteria + +- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. +- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. +- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. + +## Risks + +Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..eefe6f2cc4 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,36 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: proposed + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 提案 + +Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 + +Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 + +运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 + +## 验收标准 + +- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 +- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 + +## 风险 + +依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 From 97c5ca940d449d5a4b164984dc2ffcc6878ef841 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 16:58:56 +0800 Subject: [PATCH 06/28] review fix: preserve blocked-prompt RFC contract --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../simplification/2026-07-17-one-send-one-turn.md | 4 ++-- .../2026-07-17-one-send-one-turn.zh.md | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml index d485306ae6..36bd0699c4 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 2542b1038be07b5590ff80a02080b8872558f71c -2026-07-17-one-send-one-turn.zh.md: eefe6f2cc43c544dba4a67f95b36a27a852d7439 +2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b +2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md index 2542b1038b..21ab23a9ab 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md @@ -16,7 +16,7 @@ A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the du The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will end that turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. +Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. @@ -28,7 +28,7 @@ Running `steer()` will continue to append to the active turn's steering FIFO. Id - Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. - Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto, cancellation, disposal, and turn-start failure cannot merge messages or leave the agent permanently running. +- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md index eefe6f2cc4..07e268fcfa 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,27 +8,27 @@ Status: proposed `Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 -共享轮次也会共享 prompt admission、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。allowed 与 blocked prompt 混合批次的分支引入了调用方从未显式请求的生命周期状态。 +共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前 active turn,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 提案 Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 -Prompt admission 将只处理一条消息。allowed prompt 会成为该轮次的 `user/message`;blocked prompt 会让该轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 +提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 -运行中的 `steer()` 仍会追加到 active turn 的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的 prompt 可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 ## 验收标准 - 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、session listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- Prompt veto、取消、dispose 和 turn-start failure 不能合并消息,也不能让 agent 永久停留在 running 状态。 +- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 +- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 ## 风险 From aa4e62987473c878eaf1a0ee117ebcd8b6327337 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:14:52 +0800 Subject: [PATCH 07/28] feat(agent-loop): give each send its own turn --- docs/architecture.md | 10 +- docs/core-data-structures/core.md | 11 +- docs/core-data-structures/session.md | 12 +- docs/defensive-patterns.md | 2 +- docs/persistence-catalog.md | 4 +- docs/rfc/INDEX.md | 2 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 38 +++++ .../2026-07-17-one-send-one-turn.zh.md | 38 +++++ .../2026-07-17-one-send-one-turn.md | 36 ----- .../2026-07-17-one-send-one-turn.zh.md | 36 ----- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/inbox.ts | 12 +- packages/core/agent-loop/src/loop.ts | 60 +++----- packages/core/agent-loop/tests/cancel.spec.ts | 9 +- .../tests/contract-regressions.spec.ts | 40 ++++-- .../agent-loop/tests/coverage-edges.spec.ts | 16 ++- packages/core/agent-loop/tests/inbox.spec.ts | 10 +- .../agent-loop/tests/interception.spec.ts | 38 ++--- packages/core/agent-loop/tests/loop.spec.ts | 130 +++++++++++++++++- .../core/agent-loop/tests/properties.spec.ts | 41 ++++-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 8 +- packages/core/session/src/types.ts | 10 +- packages/ui/acp/src/index.ts | 4 +- 26 files changed, 372 insertions(+), 209 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-17-one-send-one-turn.i18n.yaml (65%) create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md create mode 100644 docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..b0449be8ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is one agent's append-only event log. An ordinary **turn** claims one queued message; an injection turn claims none. A turn ends when the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus its tool executions. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. ### Turn Flow @@ -64,13 +64,13 @@ prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for queued messages + wait for a queued message emit agent/status(running) TURN: 'turn/start' - each queued message -> agent/prompt-submit + claimed message -> agent/prompt-submit allowed prompt -> 'user/message' plus injected context - every prompt blocked -> 'turn/end'(rejected) + blocked prompt -> 'prompt/blocked' -> 'turn/end'(rejected) STEP loop: drain steering assemble system prompt and tool schemas @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step has one prompt assembly. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unresolved references fail the turn. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 340212d37a..8a78dbc79c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -262,8 +262,9 @@ interface Agent { readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. Unless cleared before turn start, the + * item becomes the sole ordinary message in its turn and waits for every + * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly @@ -308,8 +309,8 @@ interface Agent { * - drops a turn that is about to start (a `cancel()` landing in the * pre-step window — after a `send()` queued but before the loop flips to * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. + * that queued prompt does not run; later accepted items remain independent + * queued turns. * * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. * `cancel()` on an idle agent with nothing queued or running is a safe no-op @@ -364,7 +365,7 @@ interface HookContext { } ``` -`agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContext` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): +`agent/prompt-submit` returns a `PromptDecision` (allow the turn's claimed queued message — optionally rewriting its `content` or attaching `additionalContext` — or record `prompt/blocked` and end that zero-step turn as `rejected`): ```ts type-equiv type PromptDecision = diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..18d511a0a0 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -19,9 +19,8 @@ interface SessionEventMap { /** * A queued prompt an `agent/prompt-submit` listener VETOED — the durable * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` + * the prompt would have become; that one-message turn runs zero steps and + * ends `rejected`. `content` * is the original prompt the listener rejected; `reason` is the veto text * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a * blocked prompt produces no LLM message and never reaches `deriveMessages()`. @@ -268,9 +267,8 @@ interface TurnEndReasonMap { disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block + * The turn's claimed prompt was BLOCKED before any step ran by an + * `agent/prompt-submit` listener (a hook). The turn still opened (so the boundary stays balanced and the block * is a durable in-turn fact), but ran zero steps. `reason` carries the block * message from the vetoing decision. Distinct from `aborted` (a user-driven * cancel) and `error` (a failure): the prompt was rejected by policy, not @@ -291,7 +289,7 @@ interface TurnEndReasonMap { } ``` -`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose whole prompt batch an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. +`max-tokens` mirrors the model-call `FinishReason` of the same name: any `max-tokens` step in a turn makes the whole turn end `max-tokens` rather than `completed` (the cut-short fact wins over a later continuation), so a consumer can tell a clean stop from a truncated one — but only over `completed`: the `disposed`/`aborted`/`error` outcomes take precedence. `rejected` is a zero-step turn whose claimed prompt an `agent/prompt-submit` hook blocked (the ACP bridge maps it to `cancelled`). `interrupted` is the one reason no loop emits — it is synthesized by crash recovery (see [persistence.md](persistence.md)). Both maps are merge-extensible. ## The turn-enclosure invariant diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index cf30072094..fe74a9d19f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma ## Async state is not synchronous state -`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns (the loop batches queued messages). The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. +`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly. ## Dispose must reach quiescence, not just request it diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a27b19b9a7..64a0625f36 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -169,7 +169,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, and its turn runs zero steps. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -305,7 +305,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ #### `turn/start` — log-only -Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). +Opens turn `turn`. `trigger` records what started it — one claimed queued message or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). ```ts persistence-catalog 'turn/start': { turn: number; trigger: TurnTrigger } diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c0992414af..b382cbc945 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,7 +22,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 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | -| [Give each ordinary send its own turn](proposed/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture @@ -108,6 +107,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [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 | +| [Give each ordinary send its own turn](implemented/simplification/2026-07-17-one-send-one-turn.md) | 2026-07-17 | ### Architecture diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..669e577625 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,7 +14,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. @@ -30,11 +30,11 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat Core dispatch and the tool body sit inside normalization boundaries, so tool, listener, malformed-result, non-JSON result, and identity-shape failures resolve as JSON-safe `isError` results rather than escaping the turn. A post-execute listener can therefore inspect a thrown tool, and a final observer sees exactly what the caller receives and the session log can persist. -**`TurnEndReason.rejected`** (`dsh-session`): a turn whose entire prompt batch was blocked by `prompt-submit`. +**`TurnEndReason.rejected`** (`dsh-session`): a zero-step turn whose claimed prompt was blocked by `prompt-submit`. ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while allowed `additionalContext` is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml similarity index 65% rename from docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml rename to docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 36bd0699c4..bcb9485da5 100644 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 21ab23a9ab0e7ea11fcaf44252b6e5d8e49c224b -2026-07-17-one-send-one-turn.zh.md: 07e268fcfa8651977c204baf46b499c62c27626a +2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f +2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md new file mode 100644 index 0000000000..34331a04b5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -0,0 +1,38 @@ +# RFC: Give each ordinary send its own turn + +Status: implemented + +English | [中文](2026-07-17-one-send-one-turn.zh.md) + +## Problem + +An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. + +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. + +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. + +## Decision + +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. + +Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. + +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. + +## Alternatives considered + +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. + +## Verification + +- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. +- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. +- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. + +## Consequences + +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. + +Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md new file mode 100644 index 0000000000..e6c4b97e82 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -0,0 +1,38 @@ +# RFC: 让每次普通 send 独占一个轮次 + +Status: implemented + +[English](2026-07-17-one-send-one-turn.md) | 中文 + +## 问题 + +每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 + +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 + +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 + +## 决策 + +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 + +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 + +运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 + +## 曾考虑的替代方案 + +**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 + +## 验证 + +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 +- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 + +## 后果 + +普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 + +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md deleted file mode 100644 index 21ab23a9ab..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: Give each ordinary send its own turn - -Status: proposed - -English | [中文](2026-07-17-one-send-one-turn.zh.md) - -## Problem - -`Agent.send()` snapshots one ordinary message and appends it to a FIFO, but the agent loop drains every waiting ordinary message into one turn. Whether adjacent sends share a turn depends on when the driver happens to dequeue: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks can observe different grouping even though callers used the same API. - -A shared turn also shares prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. A later message can therefore join an earlier message's model request instead of observing the earlier turn's committed result. The batching branches for mixed allowed and blocked prompts add lifecycle states that no caller explicitly requests. - -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching makes `send()` overlap both explicit operations instead of preserving a single meaning. - -## Proposal - -The inbox will dequeue at most one ordinary message for each turn start. A successful `send()` will remain synchronous: it validates agent state, snapshots and freezes content, appends one FIFO item, and publishes `agent/queued`. If two items are both claimed, the second turn will start only after the first turn ends and its durability checkpoint completes; an item discarded before turn start will not create an empty turn. - -Prompt admission will decide one message. An allowed prompt will become that turn's `user/message`; a blocked prompt will append one durable `prompt/blocked` and end that one-message turn as `rejected`. The mixed-batch and all-blocked-batch branches will disappear. - -Running `steer()` will continue to append to the active turn's steering FIFO. Idle `steer()` will continue to delegate to `send()` and therefore create an independent ordinary turn. `inject()` will retain its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` will remain whole-agent operations rather than per-message controls. - -## Alternatives considered - -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; a future measured batching feature would need an explicit caller-visible contract. - -## Acceptance criteria - -- Two adjacent successful sends remain distinct FIFO items and, when both are claimed, produce two turns separated by the first turn's durability checkpoint. -- Dequeue timing and reentrant sends from queued listeners, session listeners, and model callbacks do not change the one-message turn boundary. -- Prompt veto appends one durable `prompt/blocked` for its `rejected` turn; cancellation, disposal, and `turn/start` failure cannot merge messages or leave the agent permanently `running`. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their documented meanings. - -## Risks - -Workloads that intentionally relied on coincidental batching will make more model requests and may take longer to drain. FIFO queues may also grow under sustained producers. The proposal accepts those costs because the public `send()` boundary becomes deterministic; throughput optimization can return only with an explicit measured contract. diff --git a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md deleted file mode 100644 index 07e268fcfa..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-17-one-send-one-turn.zh.md +++ /dev/null @@ -1,36 +0,0 @@ -# RFC: 让每次普通 send 独占一个轮次 - -Status: proposed - -[English](2026-07-17-one-send-one-turn.md) | 中文 - -## 问题 - -`Agent.send()` 会为一条普通消息创建快照,并将其追加到 FIFO,但 agent loop(智能体循环)会把所有等待中的普通消息一起取出并放入同一个轮次。相邻 send 是否共享轮次取决于 driver 何时恰好出队:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 - -共享轮次也会共享提示词准入、`turn/start`、`turn/end` 和持久性检查点。因此,后一条消息可能加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果。获准与被阻止提示词混合批次的分支引入了调用方从未显式请求的生命周期状态。 - -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 - -## 提案 - -Inbox 在每次轮次开始时最多取出一条普通消息。成功的 `send()` 仍为同步调用:它会校验 agent 状态、创建并冻结内容快照、追加一个 FIFO item,然后发布 `agent/queued`。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;在轮次开始前被丢弃的 item 不会创建空轮次。 - -提示词准入将只处理一条消息。获准提示词会成为该轮次的 `user/message`;被阻止提示词会追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。mixed-batch 和 all-blocked-batch 分支将被删除。 - -运行中的 `steer()` 仍会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 仍会委托给 `send()`,因此会创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 - -## 曾考虑的替代方案 - -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来若根据测量结果重新引入批处理,必须提供调用方可见的显式契约。 - -## 验收标准 - -- 相邻两次成功 send 始终是两个独立 FIFO item;如果两者都被认领,则形成两个轮次,并由第一个轮次的持久性检查点隔开。 -- 出队时机,以及 queued listener、会话 listener 和模型 callback 中的重入 send,都不能改变一条消息对应一个轮次的边界。 -- 提示词否决会为对应的 `rejected` 轮次追加一条持久的 `prompt/blocked`;取消、dispose(资源释放)和 `turn/start` 失败不能合并消息,也不能让 agent 永久停留在 `running` 状态。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持文档中的含义。 - -## 风险 - -依赖偶然批处理的工作负载会产生更多模型请求,队列清空时间也可能延长。持续 producer 还可能让 FIFO 队列增长。本提案接受这些成本,因为公共 `send()` 边界会变得确定;只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..88efc953a7 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index abb588b919..b26a79a1ef 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -15,7 +15,7 @@ export interface InboxMessage { } /** - * Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO + * Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO * (drained between steps of a running turn). Purely an in-memory mechanism of * the loop — the public surface is `Agent.send()` / `Agent.steer()`. */ @@ -54,11 +54,11 @@ export class Inbox { } /** - * Drain all queued messages (turn start). - * @returns the drained messages in arrival order; the queued FIFO is left empty. + * Remove the oldest queued message for one turn start. + * @returns the oldest message, or `undefined` when the queued FIFO is empty. */ - drainQueued(): InboxMessage[] { - return this.queuedMessages.splice(0) + dequeueQueued(): InboxMessage | undefined { + return this.queuedMessages.shift() } /** @@ -72,7 +72,7 @@ export class Inbox { /** * Discard all pending messages (queued + steering) without delivering them — * used by `cancel()`, which drops un-started work rather than draining it into - * a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away. + * a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away. */ clear(): void { this.queuedMessages.length = 0 diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 15cc0aa410..ebe528c5e0 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -88,7 +88,7 @@ export interface LoopHandle { } /** - * Drive queued batches as durable turns until disposal. Plugin failures end the + * Drive queued messages as independent durable turns until disposal. Plugin failures end the * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). @@ -159,12 +159,11 @@ async function runTurn( ): Promise { const { session } = agent - // Drain before opening the turn, but append only after `turn/start`. - const queued = handle.inbox.drainQueued() - const first = queued[0] + // Claim one queued message before opening its turn, but append it only after `turn/start`. + const message = handle.inbox.dequeueQueued() /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ - if (!first) throw new Error('runTurn invariant violated: no queued message at turn start') - const trigger: TurnTrigger = { kind: 'message', source: first.source } + if (!message) throw new Error('runTurn invariant violated: no queued message at turn start') + const trigger: TurnTrigger = { kind: 'message', source: message.source } let reason: TurnEndReason = { kind: 'completed' } let step = 0 @@ -202,51 +201,32 @@ async function runTurn( // matter what throws below; the catch + closeTurn guarantee it. A pre-commit // veto leaves no turn/start in the log and therefore owes no turn/end. session.append('turn/start', { turn, trigger }) - // Each drained queued message runs the `agent/prompt-submit` waterfall before - // it becomes a `user/message` — a hook can rewrite the prompt or block it. + // The claimed message runs the `agent/prompt-submit` waterfall before it + // becomes a `user/message` — a hook can rewrite the prompt or block it. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; // turn/end is now owed, so a throwing prompt-submit listener (the waterfall // throws) is caught below and the turn still closes. - let anyAllowed = false - // Seeded with a floor (only observable if the batch were empty, which - // runTurn never allows — it is called with ≥1 queued message); each `block` - // decision carries a required `reason` and overwrites it, so a fully-blocked - // batch always reports the last vetoing reason. - let lastBlockReason = 'prompt blocked by hook' - for (const message of queued) { - const decision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, - () => Promise.resolve({ kind: 'allow' }), - ) - if (decision.kind === 'block') { - lastBlockReason = decision.reason - // Record the veto durably: `PromptDecision.reason` is the durable record - // of why a prompt was blocked, but a fully-blocked batch's `rejected` - // turn/end only preserves the LAST reason, and a MIXED batch (this prompt - // blocked, another allowed) does not end `rejected` at all — so without - // this append a blocked prompt would vanish from the log whenever any - // sibling prompt is allowed. `prompt/blocked` sits in the open turn in - // place of the `user/message` this prompt would have become. - session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason }) - continue - } - anyAllowed = true + const promptDecision = await events.waterfall( + 'agent/prompt-submit', message.content, message.source, + () => Promise.resolve({ kind: 'allow' }), + ) + if (promptDecision.kind === 'block') { + session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) + reason = { kind: 'rejected', reason: promptDecision.reason } + } else { // `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them. - const content = decision.content ?? message.content + const content = promptDecision.content ?? message.content session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' }) // `allow.additionalContext` is a SEPARATE context/message the next request // also sees. The turn is open, so inject() appends it into THIS turn. - if (decision.additionalContext) { - agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source }) + if (promptDecision.additionalContext) { + agent.inject(promptDecision.additionalContext.content, { source: promptDecision.additionalContext.source }) } } while (true) { - // A fully blocked batch closes its zero-step turn as rejected. - if (!anyAllowed) { - reason = { kind: 'rejected', reason: lastBlockReason } - break - } + // A blocked prompt closes its zero-step turn as rejected. + if (promptDecision.kind === 'block') break step += 1 // Steering from the previous round's continuation listeners joins before diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cad830e827..5ed15f1b0b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -75,7 +75,8 @@ describe('Agent.cancel()', () => { // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. - send(agent, 'drop me') + send(agent, 'drop me first') + send(agent, 'drop me second') agent.cancel('pre-step') // Give the loop a chance to wake and process the cancel. @@ -106,7 +107,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) - it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -117,10 +118,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + send(agent, 'queued tail') agent.cancel('mid-step') await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }]) + expect(userTexts(agent)).toEqual(['go']) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(adapter.requests).toHaveLength(1) }) it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ee50261e9d..33e0b7de9c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -276,15 +276,20 @@ describe('plugin exceptions are contained', () => { expect(agent.status).toBe('idle') }) - it('a rejecting session/flush listener is reported but does not kill the agent', async () => { + it('a rejecting first-turn flush settles before the queued tail starts', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - let rejectedOnce = false - ctx.on('session/flush', async () => { - if (!rejectedOnce) { - rejectedOnce = true + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise throw new Error('disk full') } }) @@ -292,18 +297,25 @@ describe('plugin exceptions are contained', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['disk full']) - send(agent, 'second') - await waitForIdle(ctx, agent) + + await firstFlush.promise + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(errors.map(e => e.message)).toEqual(['disk full']) expect(adapter.requests).toHaveLength(2) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) }) }) describe('disposed status is part of the agent/status contract', () => { - it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { + it('disposing the fiber ends the active turn and never starts its queued tail', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -319,11 +331,19 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) + send(agent, 'queued tail') await fiber.dispose() await agent.done expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .flatMap(event => event.data.content) + .flatMap(block => block.type === 'text' ? [block.text] : []) + expect(messages).toEqual(['go']) + expect(adapter.requests).toHaveLength(1) }) it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 5deee8e159..c85c5ef949 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -141,12 +141,22 @@ describe('toError normalization', () => { const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - send(agent, 'go') + send(agent, 'fails before turn start') + send(agent, 'survives as the next item') await waitForIdle(ctx, agent) expect(errors).toHaveLength(1) expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' }) - expect(adapter.requests).toEqual([]) - expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false) + expect(adapter.requests).toHaveLength(1) + const starts = agent.session.events.filter(event => event.type === 'turn/start') + const ends = agent.session.events.filter(event => event.type === 'turn/end') + const messages = agent.session.events.filter(event => event.type === 'user/message') + expect(starts).toHaveLength(1) + expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1) + expect(ends).toHaveLength(1) + expect(messages).toHaveLength(1) + expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([ + { type: 'text', text: 'survives as the next item' }, + ]) }) it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 4bea62abe2..f4eea9fdd0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -8,17 +8,17 @@ function resolverPair() { } describe('Inbox', () => { - it('enqueues and drains queued messages in FIFO order', () => { + it('dequeues one queued message at a time in FIFO order', () => { const inbox = new Inbox() inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } }) inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } }) expect(inbox.hasQueued).toBe(true) - const drained = inbox.drainQueued() - expect(drained).toHaveLength(2) - expect(drained[0]!.content[0]).toMatchObject({ text: 'first' }) - expect(drained[1]!.content[0]).toMatchObject({ text: 'second' }) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' }) + expect(inbox.hasQueued).toBe(true) + expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' }) expect(inbox.hasQueued).toBe(false) + expect(inbox.dequeueQueued()).toBeUndefined() }) it('pushes and drains steering messages separately from queued', () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 4b37210993..ad0ea7d199 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -182,9 +182,7 @@ describe('agent/prompt-submit', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' }) }) - it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { - // Blocking one prompt in a mixed batch must persist its reason even though - // the allowed prompt keeps the turn from ending rejected. + it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => { const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -197,13 +195,13 @@ describe('agent/prompt-submit', () => { const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) - // both sends land before the loop drains → one batched turn + // Both sends land before the driver wakes, but each remains its own turn. send(agent, 'secret') send(agent, 'safe') await waitForIdle(ctx, agent) const log = events(agent) - // the allowed prompt became a user/message and drove exactly one model call + // The allowed prompt became a user/message and drove exactly one model call. const userMsgs = log.filter(e => e.type === 'user/message') expect(userMsgs).toHaveLength(1) expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) @@ -215,12 +213,14 @@ describe('agent/prompt-submit', () => { content: [{ type: 'text', text: 'secret' }], reason: 'policy: no secrets', }) - // the turn did NOT reject — a sibling was allowed — so the boundary reason - // alone would not have preserved the block - expect(reasons.some(r => r.kind === 'rejected')).toBe(false) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'rejected', reason: 'policy: no secrets' }, + { kind: 'completed' }, + ]) }) - it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { + it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -233,18 +233,18 @@ describe('agent/prompt-submit', () => { const errors: Error[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + const idle = waitForIdle(ctx, agent) send(agent, 'first') - await waitForIdle(ctx, agent) - expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // turn balanced - const log = events(agent) - expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) - expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1) - - // loop survives: a second prompt runs normally send(agent, 'second') - await waitForIdle(ctx, agent) - expect(adapter.requests.length).toBeGreaterThanOrEqual(1) + await idle + expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) + // The failed prompt owns one balanced error turn; the adjacent prompt owns + // the following normal turn without an intermediate idle transition. + const log = events(agent) + expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) + expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(adapter.requests).toHaveLength(1) + expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..368ce0c6f1 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -827,7 +827,104 @@ describe('agent loop', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) - it('chains queued messages into consecutive turns', async () => { + it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + send(agent, 'second message') + + await firstFlush.promise + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(flushes).toBe(2) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a reentrant agent/queued send as the next independent turn', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + let nested = false + ctx.on('agent/queued', (subject) => { + if (subject !== agent || nested) return + nested = true + send(agent, 'queued listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const turns = agent.session.events.filter(event => event.type === 'turn/start') + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(turns).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'queued listener message' }], + ]) + }) + + it('preserves independent turn sources across an adjacent microtask send', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const idle = waitForIdle(ctx, agent) + agent.send([{ type: 'text', text: 'user message' }]) + await Promise.resolve() + agent.send( + [{ type: 'text', text: 'plugin message' }], + { source: { kind: 'plugin', plugin: 'test' } }, + ) + await idle + + const triggers = agent.session.events + .filter(event => event.type === 'turn/start') + .map(event => event.data.trigger) + const sources = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.source) + expect(triggers).toEqual([ + { kind: 'message', source: { kind: 'user' } }, + { kind: 'message', source: { kind: 'plugin', plugin: 'test' } }, + ]) + expect(sources).toEqual([ + { kind: 'user' }, + { kind: 'plugin', plugin: 'test' }, + ]) + }) + + it('keeps a session-listener send after dequeue in the following turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -850,6 +947,37 @@ describe('agent loop', () => { expect(turns).toEqual([1, 2]) expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') + }) + + it('keeps a model-adapter callback send in the following turn', async () => { + const agentRef: { current?: ReactLoopAgent } = {} + const adapter = new MockAdapter([ + () => { + const agent = agentRef.current + if (agent === undefined) throw new Error('model callback ran before agent setup') + send(agent, 'model callback message') + return textResponse('first') + }, + textResponse('second'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agentRef.current = agent + + const idle = waitForIdle(ctx, agent) + send(agent, 'outer message') + await idle + + const messages = agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(messages).toEqual([ + [{ type: 'text', text: 'outer message' }], + [{ type: 'text', text: 'model callback message' }], + ]) }) it('awaits session/flush at turn end (persistence checkpoint)', async () => { diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index dbc43ad985..82b103d664 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -75,6 +75,21 @@ function turnNumbers(agent: ReactLoopAgent): number[] { .map(e => (e.data as { turn: number }).turn) } +function turnEndNumbers(agent: ReactLoopAgent): number[] { + return agent.session.events + .filter(e => e.type === 'turn/end') + .map(e => (e.data as { turn: number }).turn) +} + +function userMessageCountsByTurn(agent: ReactLoopAgent): number[] { + const counts: number[] = [] + for (const event of agent.session.events) { + if (event.type === 'turn/start') counts.push(0) + if (event.type === 'user/message') counts[counts.length - 1]! += 1 + } + return counts +} + /** Assert a status trace is a legal run: idle/running alternating, ending idle. */ function assertLegalStatusTrace(trace: string[]): void { for (let i = 1; i < trace.length; i++) { @@ -84,7 +99,7 @@ function assertLegalStatusTrace(trace: string[]): void { } describe('agent loop scheduling properties', () => { - it('a synchronous burst loses no message and uses strictly increasing turns', async () => { + it('a synchronous burst gives every message its own strictly increasing turn', async () => { await fc.assert(fc.asyncProperty( fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }), async (texts) => { @@ -99,8 +114,11 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // A synchronous burst batches into exactly one turn. - expect(turnNumbers(agent)).toEqual([1]) + // Every successful send owns an independent turn even before the driver wakes. + expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) + expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) + expect(trace).toEqual(['running', 'idle']) assertLegalStatusTrace(trace) } finally { await ctx.fiber.dispose() @@ -131,9 +149,9 @@ describe('agent loop scheduling properties', () => { ), { numRuns: 20, timeout: 2000 }) }) - it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => { - // Each step is a (text, settle?) pair: settle=true awaits idle before the - // next send (own turn); settle=false sends in the same tick (batches). + it('mixed settled and same-tick sends preserve one turn per message', async () => { + // Each step optionally waits for idle before the next send; that scheduling + // choice must not change the ordinary message-to-turn mapping. const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() }) await fc.assert(fc.asyncProperty( fc.array(stepArb, { minLength: 1, maxLength: 6 }), @@ -152,14 +170,13 @@ describe('agent loop scheduling properties', () => { } await lastIdle - // No message lost or reordered, regardless of batching. + // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Turn numbers are a strictly increasing 1..N prefix (N = turn count). + // Every send owns exactly one turn, numbered in FIFO order. const turns = turnNumbers(agent) - expect(turns).toEqual(turns.map((_, i) => i + 1)) - // Every message landed in some turn; turns never exceed messages. - expect(turns.length).toBeLessThanOrEqual(steps.length) - expect(turns.length).toBeGreaterThanOrEqual(1) + expect(turns).toEqual(steps.map((_, i) => i + 1)) + expect(turnEndNumbers(agent)).toEqual(turns) + expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1)) } finally { await ctx.fiber.dispose() } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..e1e5b27f22 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -39,7 +39,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..62d5b2695e 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,7 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and * `additionalContext` becomes a separate context message. `block` records a - * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. + * durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } @@ -90,7 +90,7 @@ export interface Agent { readonly ctx: Context /** - * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -111,8 +111,8 @@ export interface Agent { inject(content: ContentBlock[], options?: SendOptions): void /** - * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active + * Clear all queued and steering work, including items waiting to start, and + * abort the active step. The supplied reason is preserved across pre-step and active * cancellation windows, and `whenIdle()` resolves after cancellation reaches * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..131deec325 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -105,8 +105,8 @@ export interface TurnEndReasonMap { /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * Policy blocked every prompt before the first step. The zero-step turn still - * records a balanced durable boundary and the veto reason. + * Policy blocked the turn's claimed prompt before the first step. The + * zero-step turn still records a balanced durable boundary and veto reason. */ rejected: { kind: 'rejected'; reason: string } /** @@ -209,8 +209,8 @@ export interface ToolsDelta { */ export interface SessionEventMap { /** - * Opens turn `turn`. `trigger` records what started it — a drained message - * batch or an idle-time injection. The turn is the durability/replay + * Opens turn `turn`. `trigger` records what started it — one claimed queued + * message or an idle-time injection. The turn is the durability/replay * boundary: every event sits between a `turn/start` and its matching * `turn/end` (the turn-enclosure invariant). */ @@ -229,7 +229,7 @@ export interface SessionEventMap { 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, including in a mixed batch. + * prompt never enters the model-visible surface, and its turn runs zero steps. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c464ffd22b..2907334b69 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -709,8 +709,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // session/cancel maps to the queue-aware agent.cancel(reason): it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a // turn that is about to start (the pre-step window) — so a queued-but- - // not-yet-started prompt never runs, and a prompt accepted right after - // cannot be batched into the cancelled turn. Scoped to THIS session's + // not-yet-started prompt never runs, while a prompt accepted afterward + // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to From 2cf689301c337c53739e4cebcc37929c06e325ff Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:27:53 +0800 Subject: [PATCH 08/28] review fix: close one-turn lifecycle gaps --- packages/core/agent-loop/src/loop.ts | 1 + packages/core/agent-loop/tests/cancel.spec.ts | 30 +++++++++ .../agent-loop/tests/interception.spec.ts | 11 ++++ packages/core/agent-loop/tests/loop.spec.ts | 65 +++++++++++++++++-- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b26d09e5c1..80b6b27eaf 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -117,6 +117,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } handle.setStatus('running') + if (handle.isDisposed()) break // A synchronous `running` listener can cancel before `runTurn`; balance the // status only when no replacement prompt was queued by that listener. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5ed15f1b0b..dd02afdec7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -88,6 +88,36 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('disposal from the running notification drops queued work before turn start', async () => { + const adapter = new MockAdapter([textResponse('should not run')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('a-dispose-running'), + sessionId: SessionId('dispose-running-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent as ReactLoopAgent + + const running = Promise.withResolvers() + let disposalDone: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'running') return + disposalDone = handle.dispose() + running.resolve(undefined) + }) + + send(agent, 'drop before claim') + await running.promise + if (disposalDone === undefined) throw new Error('running listener did not start disposal') + await disposalDone + await agent.done + + expect(agent.status).toBe('disposed') + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(userTexts(agent)).toEqual([]) + expect(adapter.requests).toHaveLength(0) + }) + it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 57b773de8a..8663875c6b 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -239,7 +239,13 @@ describe('agent/prompt-submit', () => { return { kind: 'allow' as const } }) const errors: Error[] = [] + const reasons: TurnEndReason[] = [] + const statuses: string[] = [] ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error)) + ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) + }) const idle = waitForIdle(ctx, agent) send(agent, 'first') @@ -251,6 +257,11 @@ describe('agent/prompt-submit', () => { const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(reasons).toEqual([ + { kind: 'error', step: 0, message: 'prompt hook broke' }, + { kind: 'completed' }, + ]) + expect(statuses).toEqual(['running', 'idle']) expect(adapter.requests).toHaveLength(1) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second') }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index aff13ddd5c..0e223563b7 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -349,14 +349,24 @@ describe('agent loop', () => { expect(flat).toContain('change of plans') }) - it('steering while idle behaves like send (starts a turn)', async () => { - const adapter = new MockAdapter([textResponse('ok')]) + it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => { + const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - agent.steer([{ type: 'text', text: 'hello' }]) - await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'first idle steer' }]) + agent.steer([{ type: 'text', text: 'second idle steer' }]) + await idle + + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.data.content)).toEqual([ + [{ type: 'text', text: 'first idle steer' }], + [{ type: 'text', text: 'second idle steer' }], + ]) + expect(adapter.requests).toHaveLength(2) }) it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { @@ -893,6 +903,51 @@ describe('agent loop', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message') }) + it('holds a turn-end listener send behind the closing turn checkpoint', async () => { + const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + const firstFlush = Promise.withResolvers() + const releaseFirstFlush = Promise.withResolvers() + let flushes = 0 + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushes += 1 + if (flushes === 1) { + firstFlush.resolve(undefined) + await releaseFirstFlush.promise + } + }) + + const turns: number[] = [] + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'turn/start') turns.push(event.data.turn) + if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message') + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'first message') + await firstFlush.promise + + expect(turns).toEqual([1]) + expect(adapter.requests).toHaveLength(1) + + releaseFirstFlush.resolve(undefined) + await idle + + expect(turns).toEqual([1, 2]) + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer') + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message') + }) + it('keeps a reentrant agent/queued send as the next independent turn', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) From 0b720942dfe6e8e018d537b80364dfbd201ed27e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:31:35 +0800 Subject: [PATCH 09/28] review fix: qualify one-turn documentation --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 7 +++--- .../feature/2026-06-30-interception-seams.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 ++-- .../2026-07-17-one-send-one-turn.md | 8 +++---- .../2026-07-17-one-send-one-turn.zh.md | 24 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 2 +- 8 files changed, 26 insertions(+), 25 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 063c805272..627776bbe0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item. Queued items run as consecutive ordinary turns under one running interval, each after the prior turn's durability checkpoint. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each successful `send()` adds one FIFO item. A claimed item is the sole ordinary message in its turn and waits for the prior checkpoint to settle; cancellation, disposal, or a pre-start failure may drop it without a turn. Consecutive claimed items run under one `running` interval. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 072ad629a6..0d8c874704 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -271,9 +271,10 @@ interface Agent { readonly ctx: Context /** - * Queue one user-message FIFO item. Unless cleared before turn start, the - * item becomes the sole ordinary message in its turn and waits for every - * preceding turn's durability checkpoint. Content and the resolved source are accepted as one detached, + * Queue one user-message FIFO item. If claimed, the item becomes the sole + * ordinary message in its turn after every preceding turn's checkpoint + * settles. Broad cancellation, disposal, or a pre-start failure can drop it + * without a turn. Content and the resolved source are accepted as one detached, * deeply-frozen lossless-JSON record before notification or enqueue, so * caller or `agent/queued` listener in-place mutation cannot change later * log/model input. Throws synchronously when either value is not losslessly diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index 50ff4a3af6..aef2ef4eb5 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each ordinary send owns an independent turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md). +1. **Open the turn before prompt policy.** A blocked prompt becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. The veto records `prompt/blocked` with the original prompt and reason, while every allowed `additionalContexts` entry is injected into the open turn. Each claimed ordinary-send item is the sole message in its turn under the [one-send-one-turn simplification](../simplification/2026-07-17-one-send-one-turn.md); a pre-start drop creates no turn. 2. **Post-tool `additionalContexts` are buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but each context is a SEPARATE `context/message`, and a single step or composite tool can produce many. Appending context immediately would interleave `result(c1) → context → result(c2)` or place nested context before its outer result, breaking tool-call/result adjacency. `ToolRunContext.deferContext()` therefore collects nested-dispatch context through failures, `execute()` surfaces the ordered array on `ToolExecutionResult`, and the loop appends every entry only after every `tool/result` in the step. An accepted outer call preserves deferred contexts before decision contexts; an outer block discards deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index bcb9485da5..1d3181bb0a 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 34331a04b53f9ccf67db0baf201dec23e1c2a60f -2026-07-17-one-send-one-turn.zh.md: e6c4b97e826393ebca81d715ae3760c6068f84d0 +2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 +2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md index 34331a04b5..232e91b1d1 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,7 +8,7 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's committed result, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. @@ -18,11 +18,11 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary turn. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and prevents a later message from reliably observing the preceding turn's durable result. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. ## Verification @@ -33,6 +33,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's committed session result. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e6c4b97e82..b4dc8d37a0 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -6,33 +6,33 @@ Status: implemented ## 问题 -每个普通 `Agent.send()` payload 都是一条完整的调用方消息。如果机会式地把所有等待 payload 放入同一个轮次,相邻调用是否共享边界就会取决于 driver 时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件 listener 和模型 callback 的调用也可能产生不同分组。 +每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,而不能观察前一轮次已经提交的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立 FIFO item,然后发布 `agent/queued`。agent loop 在每次轮次开始时最多取出一个普通 item。如果两个 item 都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点完成后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的 item,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有 mixed-batch 或 all-blocked-batch 分支。 +提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会追加到当前轮次的 steering FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通轮次。`inject()` 保持现有的轮次封闭与 flush 行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当 producer 速度快于 driver 时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并使后一条消息无法可靠观察前一轮次的持久化结果。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 延迟第一个轮次的 flush 可以证明下一个排队轮次不能在检查点完成前开始,且其请求能看到前一个 assistant result;被拒绝的 flush 也会在下一个轮次开始前完成。 -- 提示词否决与 listener failure、广义取消、dispose 和提交前 `turn/start` failure 都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的 status 和 `whenIdle()` 保持原有覆盖。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项可以观察前一轮次已经提交的会话结果。多个排队 item 仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此 status 和静止状态仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续 producer 还可能让 FIFO 队列增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 88efc953a7..e899da75fc 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -44,7 +44,7 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re - `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item, shares that accepted record between `agent/queued` and the inbox, and gives the item its own ordinary turn after preceding checkpoints; malformed data throws before either boundary. Running `steer()` uses the same acceptance boundary but joins the active turn. +`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. Each `ReactLoopAgent.send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item and shares that accepted record between `agent/queued` and the inbox. If claimed, the item becomes the sole ordinary message in a turn after preceding checkpoints settle; cancellation, disposal, or a pre-start failure can drop it first. Malformed data throws before either acceptance boundary. Running `steer()` uses the same boundary but joins the active turn. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8e86700835..e56eb8cf94 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -41,7 +41,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (`context/message` event); the next request sees it. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. From 915c727e14ceeb0b3bcbc696ca779d399c555fce Mon Sep 17 00:00:00 2001 From: pku-xht Date: Fri, 17 Jul 2026 17:38:39 +0800 Subject: [PATCH 10/28] review fix: distinguish checkpoint settlement --- docs/core-data-structures/persistence.md | 2 +- docs/persistence-catalog.md | 2 +- .../2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 12 ++++++------ packages/core/agent-loop/tests/properties.spec.ts | 4 ++-- packages/core/session/src/types.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..3ae22d05d1 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06 ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. Flush is `ctx.parallel` (awaited): a turn's events are durably committed before the next turn starts, and the turn boundary is the commit boundary. A rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the commit boundary), so the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6c48285d99..9678d25594 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -293,7 +293,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/ #### `turn/end` — log-only -Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. +Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. Success commits the closed turn; rejection is reported live and does not prevent later work. ```ts persistence-catalog 'turn/end': { turn: number; reason: TurnEndReason } diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 1d3181bb0a..2d926e45c3 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 232e91b1d13ed07f230ffc2d0a190adafb29c6e2 -2026-07-17-one-send-one-turn.zh.md: b4dc8d37a0dc03a8aae1abfab4c9d473c7afd332 +2026-07-17-one-send-one-turn.zh.md: c533e0fa35a73056b2bb5ae6ca8a53e5757c55cf diff --git a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index b4dc8d37a0..c533e0fa35 100644 --- a/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/docs/rfc/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,17 +8,17 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察前一轮次关闭后写入同一会话日志的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 `steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个智能体的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -28,11 +28,11 @@ Status: implemented - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、资源释放和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个智能体的状态和 `whenIdle()` 保持原有覆盖。 +- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 +- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个智能体的观察,而不是逐消息结果。 +普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 82b103d664..1dd1a0c127 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -114,7 +114,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // Every successful send owns an independent turn even before the driver wakes. + // This failure-free fixture claims every item into an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -172,7 +172,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every send owns exactly one turn, numbered in FIFO order. + // Every item is claimed and therefore owns one FIFO-ordered turn. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f9de8962ac..dfc8a57fcd 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -221,8 +221,8 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end, so the turn - * boundary is also the durable-commit boundary. + * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. + * Success commits the closed turn; rejection is reported live and does not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ From 6ce407259927bd73dffdf0a3643111b45651af87 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 18 Jul 2026 21:53:30 +0800 Subject: [PATCH 11/28] fix(ci): avoid concurrent docs site builds --- scripts/run-gates.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9eb82b5e15..4da4998723 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -214,7 +214,6 @@ function ciPrimaryGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - pnpmScript('website-build', 'website:build', { label: 'website build' }), pnpmScript('build', 'build', { needs: ['typecheck'] }), pnpmScript('publint', 'publint', { needs: ['build'] }), pnpmScript('node-next-types', 'verify-node-next-types', { @@ -234,7 +233,6 @@ function ciStaticGates(): Gate[] { ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), - pnpmScript('website-build', 'website:build', { label: 'website build' }), ] } @@ -349,6 +347,7 @@ function docSyncLeafGates(options: { pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), + // Keep the VitePress build in this single gate because projection rewrites website/.generated. pnpmScript('docs-site', 'docs:check', { label: 'documentation site' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), ] From 5e0e4b2401a181abba82c48deedfb21648729acd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:32:23 +0800 Subject: [PATCH 12/28] fix(web): reject credentialed search redirects --- packages/web/web-search-deepseek/README.md | 2 +- .../web/web-search-deepseek/src/provider.ts | 3 +- .../tests/deepseek.spec.ts | 1 + .../tests/redirect.spec.ts | 123 ++++++++++++++++++ packages/web/web-search-exa/README.md | 2 +- packages/web/web-search-exa/src/provider.ts | 3 +- packages/web/web-search-exa/tests/exa.spec.ts | 1 + packages/web/web-search-perplexity/README.md | 2 +- .../web/web-search-perplexity/src/provider.ts | 3 +- .../tests/perplexity.spec.ts | 1 + 10 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 packages/web/web-search-deepseek/tests/redirect.spec.ts diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index e3045d7f7c..e71a769725 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -37,7 +37,7 @@ DeepSeek returns no provider-generated answer surface this provider trusts as `c Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`. -Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. +Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. ## Model Experience diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index b272565441..cd259a999d 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -127,7 +127,7 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu return { sources, truncated: false } } -/** The DeepSeek-backed search provider. */ +/** The DeepSeek-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID @@ -145,6 +145,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/messages`, { method: 'POST', + redirect: 'error', headers: { // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy // may expect `Authorization: Bearer` — send both so either resolves. diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index afca4ecce2..5a9972cfd6 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -162,6 +162,7 @@ describe('DeepSeekSearchProvider request mapping', () => { await new DeepSeekSearchProvider(options).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) const headers = init.headers as Record expect(headers['x-api-key']).toBe('ds-key') expect(headers['authorization']).toBe('Bearer ds-key') diff --git a/packages/web/web-search-deepseek/tests/redirect.spec.ts b/packages/web/web-search-deepseek/tests/redirect.spec.ts new file mode 100644 index 0000000000..100d60f390 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/redirect.spec.ts @@ -0,0 +1,123 @@ +/** + * Real HTTP coverage proves whether native `fetch` contacts a cross-origin `Location`; mocked + * request-init assertions alone cannot observe that boundary. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek' + +const TEST_API_KEY = 'redirect-test-key' +const TEST_QUERY = 'private redirect query' +const targetRequests: ReceivedRequest[] = [] + +interface ReceivedRequest { + readonly body: string + readonly headers: IncomingMessage['headers'] + readonly method?: string +} + +let redirectOrigin: string +let targetOrigin: string + +const targetServer = createServer((request, response) => { + void captureRequest(request).then((received) => { + targetRequests.push(received) + response.writeHead(204).end() + }, (error: unknown) => response.destroy(asError(error))) +}) + +const redirectServer = createServer((request, response) => { + request.resume() + const status = Number(new URL(request.url ?? '/', 'http://fixture.test').pathname.split('/')[1]) + response.writeHead(status, { location: `${targetOrigin}/collect` }).end() +}) + +beforeAll(async () => { + targetOrigin = await listen(targetServer) + redirectOrigin = await listen(redirectServer) +}) + +afterAll(async () => { + await Promise.all([close(redirectServer), close(targetServer)]) +}) + +describe('DeepSeekSearchProvider redirect policy', () => { + it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => { + targetRequests.length = 0 + const provider = new DeepSeekSearchProvider({ + apiKey: TEST_API_KEY, + baseURL: `${redirectOrigin}/${status}`, + model: 'deepseek-chat', + apiVersion: '2023-06-01', + maxTokens: 32, + maxUses: 1, + }) + + await expect(provider.search({ query: TEST_QUERY })) + .rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) + expect(targetRequests).toHaveLength(0) + }) + + it('shows default 307 following forwards the custom credential and POST body', async () => { + targetRequests.length = 0 + const body = JSON.stringify({ query: TEST_QUERY }) + await fetch(`${redirectOrigin}/307`, { + method: 'POST', + headers: { + 'x-api-key': TEST_API_KEY, + 'authorization': `Bearer ${TEST_API_KEY}`, + 'content-type': 'application/json', + }, + body, + }) + + expect(targetRequests).toHaveLength(1) + expect(targetRequests[0]).toMatchObject({ method: 'POST', body }) + expect(targetRequests[0]?.headers['x-api-key']).toBe(TEST_API_KEY) + }) +}) + +/** Read a complete request received by the redirect target. */ +function captureRequest(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = [] + request.on('data', (chunk: unknown) => { + if (typeof chunk === 'string' || chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk)) + else reject(new TypeError('unexpected HTTP request chunk')) + }) + request.once('error', reject) + request.once('end', () => { + resolve({ + ...request.method !== undefined ? { method: request.method } : {}, + headers: request.headers, + body: Buffer.concat(chunks).toString('utf8'), + }) + }) + }) +} + +/** Listen on an ephemeral loopback port and return the server origin. */ +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() as AddressInfo + return `http://127.0.0.1:${address.port}` +} + +/** Close a listening fixture server after every request has settled. */ +async function close(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) +} + +/** Normalize an unknown fixture failure for `ServerResponse.destroy`. */ +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 47a420e3ca..e4636b7b89 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. ## Model Experience diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index ffc82683b1..3c62dabfa5 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -80,7 +80,7 @@ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { return { sources, truncated: false } } -/** The Exa-backed search provider. */ +/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class ExaSearchProvider implements WebSearchProvider { readonly id = EXA_PROVIDER_ID @@ -100,6 +100,7 @@ export class ExaSearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/search`, { method: 'POST', + redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 40c7afda6f..6e29b10aa8 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -96,6 +96,7 @@ describe('ExaSearchProvider request mapping', () => { expect(fetchMock).toHaveBeenCalledOnce() const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.exa.test/search') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) expect((init.headers as Record)['authorization']).toBe('Bearer exa-key') expect(JSON.parse(init.body as string)).toEqual({ query: 'hello', diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index 909930424b..32d6fc53cc 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). +`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). ## Model Experience diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 8ec5231627..fc6cb9df19 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -82,7 +82,7 @@ export function mapPerplexityResponse(response: PerplexityResponse): WebSearchRe } } -/** The Perplexity-backed search provider. */ +/** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ export class PerplexitySearchProvider implements WebSearchProvider { readonly id = PERPLEXITY_PROVIDER_ID @@ -103,6 +103,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { try { response = await fetch(`${this.options.baseURL}/chat/completions`, { method: 'POST', + redirect: 'error', headers: { 'authorization': `Bearer ${this.options.apiKey}`, 'content-type': 'application/json', diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index 9662226bc6..b622342384 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -90,6 +90,7 @@ describe('PerplexitySearchProvider request mapping', () => { await new PerplexitySearchProvider(options).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.perplexity.test/chat/completions') + expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) expect((init.headers as Record)['authorization']).toBe('Bearer pplx-key') expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] }) }) From 80b07984c4c43769324760a3c58190b6f59efc6a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:56:02 +0800 Subject: [PATCH 13/28] docs(web): codify credentialed redirect policy --- packages/web/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/web/AGENTS.md diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md new file mode 100644 index 0000000000..1b9721d9ca --- /dev/null +++ b/packages/web/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — Web Packages + +These rules supplement the package conventions in [packages/AGENTS.md](../AGENTS.md). + +- **Reject redirects on credential-bearing provider requests.** Configure the HTTP client to fail before following any redirect response. Regression coverage must prove that the redirect target is not contacted and that every credentialed provider opts into the policy. The configured endpoint necessarily receives the initial request; this prevents automatic forwarding of credentials or request data to another origin, not compromise of the configured endpoint. From 3a20b4c551016f048a1eb23dd7d50e933641589d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 10:48:58 +0800 Subject: [PATCH 14/28] dsh-translate-docs: delegate translation writing to a subagent worktree with a draft PR --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 2e152a0072..3a6aec6b5a 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 45c6edff41a7bc21c76aeeaf14d16af824c601de -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 91ba7523705d1500150efe0eac9085ea980e80d6 +2026-07-02-bilingual-docs-and-pairing-gate.md: 865c1365939ae67e4e7dfa4bdf5c9edef5558891 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 14947f111b18a256cfe134e1e88efaad75844904 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 45c6edff41..865c136593 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent in a dedicated `.worktrees/` branch that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which stays on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 91ba752370..14947f111b 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文档,按同 PR 规则留在该 PR 的分支上。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index d935c5c4b7..b857c7760f 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -5,6 +5,10 @@ description: Use when creating or updating the bilingual counterpart of a doc in # Translating DeepSeek-Harness docs +## Delegate to a subagent in a worktree + +When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. The subagent reads this skill and follows everything below; the sections from here on address the agent actually writing the translation. Exception: a counterpart update owed to an in-flight PR belongs on that PR's branch ([same-PR rule](../../../docs/i18n/README.md)) — the subagent works in that branch's worktree instead of opening a separate PR. + **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. ## Sources of truth (read, don't re-summarize) From f2206a3f280225f4b32f6819b55644b3e5df31c2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 10:55:03 +0800 Subject: [PATCH 15/28] Address review: fix exception path, recursion stop, terminology, anchor, description --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 3a6aec6b5a..531d21aacb 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: 865c1365939ae67e4e7dfa4bdf5c9edef5558891 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 14947f111b18a256cfe134e1e88efaad75844904 +2026-07-02-bilingual-docs-and-pairing-gate.md: a3b012f7601a6eeb230e16b67ecac0e64432cb22 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8ccc596fff16b47d8990c28077e4f4083d807be4 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index 865c136593..a3b012f760 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent in a dedicated `.worktrees/` branch that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which stays on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that works in a dedicated `.worktrees/` branch and opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 14947f111b..8ccc596fff 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文档,按同 PR 规则留在该 PR 的分支上。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index b857c7760f..1561995a1d 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,13 +1,13 @@ --- name: dsh-translate-docs -description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result +description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — tells the orchestrating agent when to delegate translation to a subagent, and orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result --- # Translating DeepSeek-Harness docs ## Delegate to a subagent in a worktree -When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. The subagent reads this skill and follows everything below; the sections from here on address the agent actually writing the translation. Exception: a counterpart update owed to an in-flight PR belongs on that PR's branch ([same-PR rule](../../../docs/i18n/README.md)) — the subagent works in that branch's worktree instead of opening a separate PR. +When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From 4d037613b1045e3c458f8a7524071056c660898f Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:31:27 +0800 Subject: [PATCH 16/28] Drop worktree/branch mechanics from the delegation instruction; just use a subagent --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 531d21aacb..0bac554646 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: a3b012f7601a6eeb230e16b67ecac0e64432cb22 -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 8ccc596fff16b47d8990c28077e4f4083d807be4 +2026-07-02-bilingual-docs-and-pairing-gate.md: effcd68810cd3179e7cc5f52730e0dd8b9129e0e +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 683530479d40458c40be99cf1470a4893e97f296 diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index a3b012f760..effcd68810 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that works in a dedicated `.worktrees/` branch and opens a draft PR, so translation lands through explicit human review instead of riding along on whatever branch triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever change triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 8ccc596fff..683530479d 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其在 `.worktrees/` 下的专用分支中工作并开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意分支上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意改动上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 1561995a1d..77cc941f0c 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -5,9 +5,9 @@ description: Use when creating or updating the bilingual counterpart of a doc in # Translating DeepSeek-Harness docs -## Delegate to a subagent in a worktree +## Delegate to a subagent -When this skill fires and translations need to be written, do not translate on your current branch: spawn a subagent to do the translation work, give it a dedicated worktree under `.worktrees/` on a fresh branch, and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. +When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From 33fadcf08d6ffb1bd7a39df4afaeefaa142f1fd9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:46:16 +0800 Subject: [PATCH 17/28] Drop the draft-PR requirement; delegation is just: use a subagent --- .../2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml | 4 ++-- .../process/2026-07-02-bilingual-docs-and-pairing-gate.md | 2 +- .../process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml index 0bac554646..a1d901ad15 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-02-bilingual-docs-and-pairing-gate.md: effcd68810cd3179e7cc5f52730e0dd8b9129e0e -2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 683530479d40458c40be99cf1470a4893e97f296 +2026-07-02-bilingual-docs-and-pairing-gate.md: 3be1d5d8fd9dba20cfca34c79cb01d89fad8097a +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: a8aa8812934e755fe0175c8f3f20d194e4d24b4a diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index effcd68810..3be1d5d8fd 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -13,7 +13,7 @@ This repo's README and docs tree are read by people and agents inside and outsid - **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md). - **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. - **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), excluded (generated or bilingual-by-construction) files stay unpaired, and date-named documents on or after the manifest's `requiredSince` cutoff have complete pairs. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. -- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent that opens a draft PR, so translation lands through explicit human review instead of riding along on whatever change triggered it; the exception is a counterpart owed to an in-flight PR, which the branch-owning agent translates on that PR's branch per the same-PR rule. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. The skill directs the orchestrating agent to delegate translation writing to a subagent. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md index 683530479d..a8aa881293 100644 --- a/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md +++ b/.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -13,7 +13,7 @@ Status: implemented - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。 - **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)不得配对;凡文件名以日期开头且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,也必须有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 -- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent,由其开 draft PR,使译文经过显式的人工评审落地,而不是搭在触发它的任意改动上;例外是欠给在途 PR 的对侧文件,由持有该分支的 agent 按同 PR 规则在该 PR 的分支上翻译。 +- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../skills/dsh-code-review/SKILL.md) 模式相同:skill(技能)承载工作流,并将文档作为真源。该 skill 要求编排 agent 把翻译写作委派给 subagent。 ## 曾考虑的替代方案 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 77cc941f0c..f1edf20611 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -7,7 +7,7 @@ description: Use when creating or updating the bilingual counterpart of a doc in ## Delegate to a subagent -When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work and have it open a **draft PR** so a human reviews the translation before it lands. If you are that delegated subagent — or the counterpart update is owed to an in-flight PR whose branch you already own ([same-PR rule](../../../docs/i18n/README.md#the-gate-verify-translation-pairing)) — skip this section and translate on your current branch; the sections from here on address the agent actually writing the translation. +When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation. **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. From a6e96c47f37e12e1cb1a3a36514ba6aa060fe09e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 11:53:49 +0800 Subject: [PATCH 18/28] review fix: pin ordinary-send batching removal --- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +-- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 3 +- .../2026-07-17-one-send-one-turn.zh.md | 3 +- docs/architecture.md | 6 ++-- docs/cordis-catalog/events.md | 30 ++++++++--------- docs/core-data-structures/core.md | 14 +++++--- docs/core-data-structures/persistence.md | 2 +- docs/core-data-structures/session.md | 5 +-- docs/event-producer-consumer.md | 30 ++++++++--------- docs/persistence-catalog.md | 33 ++++++++++--------- packages/core/agent-loop/src/loop.ts | 4 +-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 14 +++++--- packages/core/session/src/types.ts | 5 +-- .../stdio-demo/tests/built-bin.e2e.ts | 17 ++++++++-- packages/ui/stdio/src/index.ts | 6 ++-- website/zh-CN/api/harness/events.md | 30 ++++++++--------- 18 files changed, 118 insertions(+), 94 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 125ef0946c..ef5bb5847a 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,7 +12,7 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later accepted prompt remains an independent queued turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer @@ -29,7 +29,7 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. @@ -106,7 +106,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Each successful `send()` adds one FIFO item whose claimed turn contains no other ordinary message; consecutive claimed items wait for the prior ordinary turn's checkpoint to settle, but can share one `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). Cancellation, disposal, or a pre-start failure may drop an item without a turn. Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). +Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..87f9e1fdd8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 675182422e..ba1b2e6156 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -362,7 +362,9 @@ interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -385,9 +387,10 @@ interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void @@ -429,7 +432,8 @@ interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9d4dc2ee6f..18ef172ef8 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The next turn waits for that checkpoint to settle. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported via `agent/error` and the logger — never as a session event (it would land past the closed turn) — and does not prevent the next turn, while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. ## Crash recovery preserves an interrupted turn diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d73f3a21fb..89a8972b98 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -34,8 +34,9 @@ interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5912accb71 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../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:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../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), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../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:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:218`](../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:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:192`](../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), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../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:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index baf8f61057..82859e2eb0 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:325`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) ### `bash/*` @@ -258,7 +258,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `hook/*` @@ -336,7 +336,7 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) ### `request/*` @@ -350,7 +350,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `steering/*` @@ -363,7 +363,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `step/*` @@ -374,7 +374,7 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:198`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -383,7 +383,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `todo/*` @@ -396,7 +396,7 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) ### `tool/*` @@ -413,7 +413,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -457,7 +457,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `turn/*` @@ -466,15 +466,16 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ ```ts persistence-catalog /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } ``` Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -503,4 +504,4 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index d7b3fb2ee7..0024623e8d 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -99,8 +99,8 @@ export interface LoopHandle { /** * Drive queued messages as independent durable turns until disposal. Plugin - * failures end the current turn without terminating the driver. The caller establishes the - * `ctx.agents.withInitiator()` boundary before entry; package-private + * failures end the current turn without terminating the driver. The caller + * establishes the `ctx.agents.withInitiator()` boundary before entry; package-private * orchestration recovers that exact Agent and captures its Session locally. * @param ctx - the plugin context the loop reaches its initiating Agent, * events (agent/…, session/flush), and services (systemPrompt, llm, tools) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f11023a248..fc6382ab2d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -54,7 +54,7 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn after the preceding checkpoint settles; broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). +- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 25ed34d694..f9bf8f2825 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -59,7 +59,8 @@ export interface HookContext { /** * Prompt interception result. `allow.content` replaces the prompt and each * `additionalContexts` entry becomes a separate context message. `block` - * records a durable `prompt/blocked` and ends that prompt's zero-step turn as rejected. + * records a durable `prompt/blocked` and ends the claimed prompt's zero-step + * turn as rejected. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } @@ -97,7 +98,9 @@ export interface Agent { readonly ctx: Context /** - * Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn. + * Queue one detached, frozen lossless-JSON item. If claimed, it is the sole + * ordinary message in its FIFO-ordered turn; the next claimed item waits for + * that turn's checkpoint. * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -120,9 +123,10 @@ export interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * abort the active step. The supplied reason is preserved across pre-step + * and active cancellation windows, and `whenIdle()` resolves after + * cancellation reaches quiescence. Idle cancellation is a no-op and does not + * arm a later cancel. */ cancel(reason?: string): void diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 11d0fb129b..df73f3fb50 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -187,8 +187,9 @@ export interface SessionEventMap { 'turn/start': { turn: number; trigger: TurnTrigger } /** * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop - * fires the awaited `session/flush` checkpoint at every turn end; the next turn waits for settlement. - * Success commits the closed turn; rejection is reported live and does not prevent later work. + * awaits `session/flush` after an ordinary turn ends before claiming the next + * queued item. Success commits the turn; rejection is reported live and does + * not prevent later work. */ 'turn/end': { turn: number; reason: TurnEndReason } /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index d3e0a32d09..c2bb459cc9 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -104,8 +104,8 @@ async function makeConsumer( return dir } -/** Run the built bin in `cwd` against `configArg` with one stdin line; resolve with stdout/stderr + exit code. */ -function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ stdout: string; code: number; stderr: string }> { +/** Run the built bin in `cwd` against `configArg` with piped stdin; resolve with stdout/stderr + exit code. */ +function runBuiltBin(cwd: string, configArg: string, input: string): Promise<{ stdout: string; code: number; stderr: string }> { return new Promise((resolve, reject) => { // --expose-internals: the cordis Loader resolves bare plugin specifiers via // its internal module loader (active only under this flag); demo:echo passes @@ -128,7 +128,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st }, 25_000) child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.write(`${line}\n`) + child.stdin.write(`${input}\n`) child.stdin.end() }) } @@ -167,6 +167,17 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-demo BUILT bin (node lib/bin.j expect(code).toBe(0) }, 30_000) + it('runs two synchronously piped lines as two ordinary turns', async () => { + consumer = await makeConsumer('TWO-TURNS ready.') + const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'first\nsecond') + expect(stderr).not.toContain('UNHANDLED') + expect(stdout).toContain('[main turn 1]') + expect(stdout).toContain('You said: "first"') + expect(stdout).toContain('[main turn 2]') + expect(stdout).toContain('You said: "second"') + expect(code).toBe(0) + }, 30_000) + it('boots when optional spill plugins are loaded from a built consumer install', async () => { consumer = await makeConsumer( 'SPILL-OK ready.', diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 6f73d948bf..720284518a 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -164,9 +164,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // immediately — no turn will ever start, so there is nothing to wait // for. (Gating on an observed 'running' here would hang forever.) // - If work WAS submitted, exit the next time the agent settles to idle - // AFTER having run. Two subtleties this handles: the loop batches - // several queued messages into ONE turn (one idle), so we don't count - // sends; and agent.send() does NOT synchronously flip status to + // AFTER having run. Later lines may steer the active turn, and consecutive + // queued turns can share one running interval, so we don't count inputs; + // agent.send() also does NOT synchronously flip status to // 'running', so requiring an observed 'running' first (`sawRunning`) // avoids exiting in the gap before the turn starts and dropping work. let stdinClosed = false diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..0f5f1132ba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L147) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L311) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca - `content` — the drained message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L175) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L226) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L278) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L241) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L188) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L165) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L252) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L288) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L298) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) ## agent-loop/* From 4ce2202721febe40056046a3f58b8a8c04dbc4aa Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 20 Jul 2026 11:56:08 +0800 Subject: [PATCH 19/28] Bound the delegation section with a header so "skip this section" is well-defined --- .agents/skills/dsh-translate-docs/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index f1edf20611..8786eb5e6b 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -9,6 +9,8 @@ description: Use when creating or updating the bilingual counterpart of a doc in When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation. +## What this skill is + **This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. ## Sources of truth (read, don't re-summarize) From d59149a22183f329665157c49ff26fe11aef7ff8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 12:51:44 +0800 Subject: [PATCH 20/28] review fix: restore idle after between-turn cancellation --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 14 ++--- .../2026-07-17-one-send-one-turn.zh.md | 16 +++--- docs/architecture.md | 2 +- docs/cordis-catalog/events.md | 8 +-- docs/core-data-structures/session.md | 2 +- docs/i18n/style-samples.md | 4 +- docs/persistence-catalog.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 5 +- packages/core/agent-loop/tests/cancel.spec.ts | 54 +++++++++++++++++++ packages/core/agent/src/types.ts | 6 +-- packages/core/session/src/types.ts | 2 +- packages/ui/acp/src/index.ts | 3 +- packages/ui/acp/tests/dispose.spec.ts | 3 +- packages/ui/acp/tests/multi-session.spec.ts | 2 +- website/zh-CN/api/harness/events.md | 12 ++--- 18 files changed, 102 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index ab47d77028..9760e026a2 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 41c2eff49e45e649fd773f02656b799bae8dbaf1 -2026-07-17-one-send-one-turn.zh.md: 69b091aca5c8aeb71b8312b8b4f888fd3742c610 +2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d +2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 41c2eff49e..9534574e76 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -A turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later message join an earlier message's model request instead of observing the earlier turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two items are both claimed, the second turn starts only after the first turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -22,18 +22,18 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Alternatives considered -**Keep opportunistic batching for throughput.** Combining queued prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future batching feature needs an explicit caller-visible contract justified by measurements. +**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. ## Verification - Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first-turn flush proves the next queued turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next turn starts. +- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. - Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding turn's closed session result after its checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. -Workloads that relied on coincidental batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Throughput optimization can return only through an explicit measured contract. +Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 69b091aca5..e3cd3c9f6c 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,32 +8,32 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条消息加入前一条消息的模型请求,无法观察同一会话日志中前一个已关闭轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop 在每个轮次开始时最多取出一个普通队列项。如果两个队列项最终都被认领,第二个轮次只能在第一个轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导) FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 -**为吞吐量保留机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条消息在前一轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 - 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个轮次的持久化刷新可以证明下一个排队轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一轮次也要等它结束后才会开始。 +- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 - 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的,被认领的 FIFO 后继项会在前一轮次关闭且其检查点处理结束后观察会话中的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入吞吐量优化。 +依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 diff --git a/docs/architecture.md b/docs/architecture.md index 2bbd9e8388..d920117fb1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events. -A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the prior turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. +A **session** is an append-only log. Each ordinary **turn** claims one queued `send()` item; injection claims none. A claimed `send()` successor awaits the preceding claimed ordinary turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it. A **step** is one model request plus tools. Below ([sequence companion](agent-lifecycle.md)), quotes mark durable events; other names are extension points. Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 87f9e1fdd8..6d73230a4b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -125,14 +125,14 @@ Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/t ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 89a8972b98..8e2eaac1a3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -43,7 +43,7 @@ interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/docs/i18n/style-samples.md b/docs/i18n/style-samples.md index 2d9de63bc6..dd970b7c12 100644 --- a/docs/i18n/style-samples.md +++ b/docs/i18n/style-samples.md @@ -28,9 +28,9 @@ **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。 -> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns. +> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. -**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要根据操作次数推断操作与轮次一一对应。 +**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。 ## ③ 测试政策清单 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 82859e2eb0..666a77e112 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -498,7 +498,7 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ #### `user/message` — surface ```ts persistence-catalog -/** A user-visible prompt (queued message drained at turn start). */ +/** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } ``` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..d1ea28850f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -652,8 +652,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, rewrite, or block one drained prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent draining its inbox.\n * @param content - the drained message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', - summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { name: 'agent/queued', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 61b661c082..4161cad92b 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Pre-step cancellation re-parks without emitting a status transition. + // Already-idle pre-start cancellation still must settle queued-work waiters. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 0024623e8d..66d4a65d69 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ + /** Settle idle waiters when pre-running cancellation finds the status already idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -126,6 +126,9 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { + // setStatus settles running→idle; the explicit settle covers the + // already-idle pre-start path where that transition is deduplicated. + handle.setStatus('idle') handle.settleIdle() continue } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 634421ed09..c582b4c943 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -140,6 +140,60 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') }) + it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + const cancelled = Promise.withResolvers() + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + // The first hop runs before runLoop resumes from runTurn; the second lands + // before its resolved waitForQueued continuation checks cancellation. + queueMicrotask(() => { + queueMicrotask(() => { + agent.cancel('between turns') + cancelled.resolve(undefined) + }) + }) + }) + + const statuses: string[] = [] + ctx.on('agent/status', (subject, status) => { + if (subject === agent) statuses.push(status) + }) + + send(agent, 'first') + send(agent, 'queued tail') + await cancelled.promise + + expect(agent.status).toBe('idle') + expect(statuses).toEqual(['running', 'idle']) + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(userTexts(agent)).toEqual(['first']) + + let idleResolved = false + void agent.whenIdle().then(() => { idleResolved = true }) + await Promise.resolve() + expect(idleResolved).toBe(true) + + const idle = waitForIdle(ctx, agent) + agent.steer([{ type: 'text', text: 'idle steer' }]) + await idle + + expect(statuses).toEqual(['running', 'idle', 'running', 'idle']) + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'idle steer']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f9bf8f2825..37481dbc1d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -207,10 +207,10 @@ declare module 'cordis' { */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index df73f3fb50..3733877258 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -196,7 +196,7 @@ export interface SessionEventMap { 'step/start': { turn: number; step: number } /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } - /** A user-visible prompt (queued message drained at turn start). */ + /** A user-visible prompt (the queued message claimed for this turn). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** * Durable record of a prompt veto and its reason. It is log-only: the blocked diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index efcc4bb18d..ca7510048b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -830,7 +830,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // not-yet-started prompt never runs, while a prompt accepted afterward // remains a separate queued turn. Scoped to THIS session's // agent — a cancel in one session never touches another's stream or - // pending prompt (RFC 011 isolation). We ALSO settle the in-flight prompt + // pending prompt (multi-session isolation). + // We ALSO settle the in-flight prompt // as cancelled directly here: do NOT rely on the resulting turn/end to // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 5cbdb6859b..8baf076d58 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -223,7 +223,8 @@ describe('acp bridge — disposal & HMR safety', () => { it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { // The factory returns a per-agent AgentHandle whose dispose() tears down - // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // EXACTLY that agent + its session — the registry's per-handle isolation + // contract. Create two agents // directly through the registry factory (the same path the ACP bridge uses), // dispose one handle, and assert the other survives, registered and // queryable, with its session still in the store. diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index 0881fe4199..efeb00f9ad 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -14,7 +14,7 @@ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[ .join('') } -describe('acp bridge — RFC 011 multi-session isolation', () => { +describe('acp bridge — multi-session isolation', () => { let storageDir: string let harness: BridgeHarness | undefined diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 0f5f1132ba..01313a79df 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -141,10 +141,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending ```ts website-api /** - * Allow, rewrite, or block one drained prompt before it becomes a user + * Allow, rewrite, or block one claimed prompt before it becomes a user * message. Call `next()` for the unchanged default. - * @param agent - the agent draining its inbox. - * @param content - the drained message's blocks, as queued. + * @param agent - the agent whose turn claimed the message. + * @param content - the claimed message's blocks, as queued. * @param source - the message's resolved source. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall @@ -152,10 +152,10 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` -Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. -- `agent` — the agent draining its inbox. -- `content` — the drained message's blocks, as queued. +- `agent` — the agent whose turn claimed the message. +- `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) From da70e4704856542ab7b2fab0f51dde07e35a1743 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:15:11 +0800 Subject: [PATCH 21/28] review fix: preserve idle waiter quiescence --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 6 +- .../2026-07-17-one-send-one-turn.zh.md | 14 ++-- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/loop.ts | 19 ++++- packages/core/agent-loop/tests/cancel.spec.ts | 76 +++++++++++++++++++ .../agent-loop/tests/interception.spec.ts | 2 +- .../core/agent-loop/tests/properties.spec.ts | 4 +- 8 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 9760e026a2..f66fa4f240 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 9534574e767d319427b6750b87ac391ed593164d -2026-07-17-one-send-one-turn.zh.md: e3cd3c9f6c8951b1f1124a139286ba5e6f3b7fbd +2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df +2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 9534574e76..852a2f24d3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -8,13 +8,13 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. -An ordinary turn owns prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. `steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items are both claimed, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. @@ -34,6 +34,6 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del ## Consequences -Ordinary turn boundaries are deterministic, and a claimed FIFO successor observes the preceding claimed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index e3cd3c9f6c..f40086ee20 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -8,13 +8,13 @@ Status: implemented 每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -普通轮次拥有提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 `send()` 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都被认领,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 @@ -22,11 +22,11 @@ Status: implemented ## 曾考虑的替代方案 -**为吞吐量保留普通 `send()` 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 `send()` 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 `send()` 的行为:每个轮次只有一条消息,并按 FIFO 排序。 +- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 @@ -34,6 +34,6 @@ Status: implemented ## 后果 -普通轮次边界是确定的;前一个已认领普通消息的轮次完成检查点处理后,被认领的 FIFO 后继项会观察该轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 -依赖普通 `send()` 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 `send()` 批处理。 +依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 4161cad92b..ba991311ae 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -398,7 +398,7 @@ export class ReactLoopAgent implements Agent { cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, withToolBatch: run => this.withToolBatch(run), - // Already-idle pre-start cancellation still must settle queued-work waiters. + // Pre-start cancellation settles queued-work waiters before publishing idle. settleIdle: () => { this.settleIdleWaiters() }, })) } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 66d4a65d69..5e8168d895 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -91,7 +91,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when pre-running cancellation finds the status already idle. */ + /** Settle idle waiters before pre-running cancellation publishes idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ readonly withToolBatch: (run: (acceptContext: (context: HookContext) => void) => Promise) => Promise @@ -118,6 +118,17 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { + // An idle listener can enqueue and cancel replacement work before the next + // wait is installed. Consume that empty marker before parking the driver. + if (handle.isCancelled()) { + handle.clearCancel() + if (!handle.inbox.hasQueued) { + handle.settleIdle() + handle.setStatus('idle') + continue + } + } + await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break @@ -126,10 +137,10 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { - // setStatus settles running→idle; the explicit settle covers the - // already-idle pre-start path where that transition is deduplicated. - handle.setStatus('idle') + // Settle before publishing idle: the already-idle path has no status + // transition, while an idle listener can register waiters for new work. handle.settleIdle() + handle.setStatus('idle') continue } } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c582b4c943..c6d84dfbf7 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -194,6 +194,82 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'idle steer']) }) + it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' }) + + let rejectFirstFlush = true + ctx.on('session/flush', (session) => { + if (session !== agent.session || !rejectFirstFlush) return + rejectFirstFlush = false + throw new Error('first flush failed') + }) + + ctx.on('agent/error', (subject, _turn, _step, error) => { + if (subject !== agent || error.message !== 'first flush failed') return + queueMicrotask(() => { + queueMicrotask(() => { agent.cancel('between turns') }) + }) + }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + send(agent, 'cancelled tail') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 }) + expect(userTexts(agent)).toEqual(['first', 'replacement']) + }) + + it('idle-listener cancellation settles its waiter without cancelling later work', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return + send(agent, 'cancelled replacement') + replacementObservation = agent.whenIdle().then(() => ({ + status: agent.status, + requests: adapter.requests.length, + turns: agent.session.events.filter(event => event.type === 'turn/start').length, + })) + agent.cancel('idle listener') + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work') + + await expect(Promise.race([ + replacementObservation, + new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)), + ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'later') + await idle + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'later']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 7142abd9d6..40981a7301 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -241,7 +241,7 @@ describe('agent/prompt-submit', () => { send(agent, 'second') await idle expect(errors.map(e => e.message)).toEqual(['prompt hook broke']) - // The failed prompt owns one balanced error turn; the adjacent prompt owns + // The failed prompt forms one balanced error turn; the adjacent prompt forms // the following normal turn without an intermediate idle transition. const log = events(agent) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 9412ffa452..85efda0e4e 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => { // No message lost: every send appears as a user/message, in order. expect(userMessageTexts(agent)).toEqual(texts) - // This failure-free fixture claims every item into an independent turn. + // This failure-free fixture maps every item to an independent turn. expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1)) expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1)) @@ -178,7 +178,7 @@ describe('agent loop scheduling properties', () => { // No message is lost or reordered, regardless of driver timing. expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text)) - // Every item is claimed and therefore owns one FIFO-ordered turn. + // Every item forms one FIFO-ordered turn containing only that message. const turns = turnNumbers(agent) expect(turns).toEqual(steps.map((_, i) => i + 1)) expect(turnEndNumbers(agent)).toEqual(turns) From 7d7d69e22512b10322659c247ea6a2069f974eca Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 13:20:51 +0800 Subject: [PATCH 22/28] review fix: cover post-cancel replacement --- packages/core/agent-loop/src/loop.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 5e8168d895..d2a27394dc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -133,7 +133,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { if (handle.isDisposed()) break // Cancellation between wake and `running` skips only the cancelled work; - // a replacement prompt still runs and owns the eventual idle transition. + // a replacement prompt still runs before the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index c6d84dfbf7..39289779be 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -270,6 +270,31 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) + it('replacement work queued after idle-listener cancellation still runs', async () => { + const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' }) + + const replacementRegistered = Promise.withResolvers() + let replacementIdle: Promise | undefined + ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return + send(agent, 'cancelled replacement') + agent.cancel('idle listener') + send(agent, 'surviving replacement') + replacementIdle = agent.whenIdle() + replacementRegistered.resolve(undefined) + }) + + send(agent, 'first') + await replacementRegistered.promise + if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') + await replacementIdle + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + }) + it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) From c16e1078f58a5576b3b95126fade6b1eee11bafd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:42:55 +0800 Subject: [PATCH 23/28] docs(agent): clarify late steering timing --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 8 ++--- .../2026-07-17-one-send-one-turn.zh.md | 8 ++--- docs/cordis-catalog/events.md | 30 +++++++++---------- docs/core-data-structures/core.md | 11 ++++--- docs/event-producer-consumer.md | 30 +++++++++---------- packages/core/agent-loop/README.md | 2 +- packages/core/agent/README.md | 4 ++- packages/core/agent/src/types.ts | 15 ++++++---- website/zh-CN/api/harness/events.md | 30 +++++++++---------- 10 files changed, 75 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index f66fa4f240..46ee3998c3 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 852a2f24d33d88933568fe1d1d937e10003202df -2026-07-17-one-send-one-turn.zh.md: f40086ee202bf74e4207081a402737d86e9dfa87 +2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 +2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 852a2f24d3..7eec39e1e5 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -10,7 +10,7 @@ An ordinary `Agent.send()` payload is one complete caller message. Opportunistic An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. -`steer()` already expresses joining the active turn, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. ## Decision @@ -18,7 +18,7 @@ Each successful `send()` synchronously validates agent state, snapshots and free Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. -Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. ## Alternatives considered @@ -30,10 +30,10 @@ Running `steer()` appends to the active turn's steering FIFO. Idle `steer()` del - A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. - A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. - Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Running and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, and broad cancellation can discard the entire unstarted tail, so status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f40086ee20..f28c939290 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -10,7 +10,7 @@ Status: implemented 普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 -`steer()` 已经用于表达加入当前轮次,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息追加到当前轮次的 steering(中途引导)FIFO。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 @@ -30,10 +30,10 @@ Status: implemented - 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 - 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 - 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 运行中与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,广义取消也可以丢弃整个未启动队尾,因此状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6d73230a4b..555f3d629a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:151`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -98,7 +98,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:271`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -121,7 +121,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -142,7 +142,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -163,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -186,7 +186,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -211,7 +211,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:282`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -237,7 +237,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:245`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -259,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -279,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -301,7 +301,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -322,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -343,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ba1b2e6156..68255bbd41 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -370,9 +370,12 @@ interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void @@ -400,7 +403,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5912accb71..94d16c584c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:151`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:160`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:208`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:218`](../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:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:245`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:192`](../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), [`stdio`](../packages/ui/stdio) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:292`](../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:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:271`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:221`](../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:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:195`](../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), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../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:305`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 91df4913ff..acb49b1ba5 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -48,7 +48,7 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` joins the active turn. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. +Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index fc6382ab2d..cedb6efed9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -55,12 +55,14 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. -- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle +- `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` +`running` describes a driver-wide drain interval, not proof that a turn is still open; it can cover turn close, the durability checkpoint, and consecutive queued turns. + ### Extension points - Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 37481dbc1d..a2ddf9280d 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -40,9 +40,9 @@ export interface InjectOptions extends SendOptions { /** * An agent's lifecycle state, emitted on every transition as `agent/status`: - * `idle` (parked, waiting for queued work), `running` (a turn is in progress), - * `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject` - * throw). + * `idle` (parked, waiting for queued work), `running` (the driver is draining + * work and may be closing or checkpointing a turn), `disposed` (terminal — no + * transition leaves it, and `send`/`steer`/`inject` throw). */ export type AgentStatus = 'idle' | 'running' | 'disposed' @@ -106,9 +106,12 @@ export interface Agent { send(content: ContentBlock[], options?: SendOptions): void /** - * Steer a running turn: content is injected between steps of the current - * turn. Uses the same owned-value and synchronous-validation boundary as - * {@link send}; when idle, behaves exactly like that method. + * Submit steering while the agent is `running`. An open turn records it at + * the next steering checkpoint before a request or continuation decision; + * policy may stop before another step. After turn close and its checkpoint, + * any remainder is queued for a later turn; terminal `agent/turn-stop`, + * cancellation, or disposal may discard it. Uses the same synchronous + * snapshot-and-validation boundary as {@link send}; when idle, delegates to it. */ steer(content: ContentBlock[], options?: SendOptions): void diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 01313a79df..b6ee4a3183 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -28,7 +28,7 @@ A fully configured agent and live session were published. Setup is composition-o - `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L154) ### agent/disposed @@ -50,7 +50,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef - `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L163) ### agent/error @@ -77,7 +77,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w - `step` — the step at which the failure surfaced. - `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L315) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L318) ### agent/post-step @@ -105,7 +105,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in - `step` — the open step number. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L271) ### agent/pre-step @@ -133,7 +133,7 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending - `step` — the pending step number. - `signal` — the turn abort signal. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L208) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L211) ### agent/prompt-submit @@ -158,7 +158,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca - `content` — the claimed message's blocks, as queued. - `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L218) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L221) ### agent/queued @@ -183,7 +183,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already - `content` — the accepted content blocks retained by the inbox. - `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L182) ### agent/request @@ -211,7 +211,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha - `step` — the step whose request this is. - `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L230) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L233) ### agent/request-error @@ -243,7 +243,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens - `retryAttempt` — zero-based number of prior recovery retries. - `signal` — the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L285) ### agent/session-prefix @@ -273,7 +273,7 @@ Compose request-only messages placed before derived history. The frozen result i - `prefix` — the frozen seed; return an extended replacement. - `signal` — aborts composition when the step is torn down. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L245) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L248) ### agent/session-start @@ -298,7 +298,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to - `agent` — the agent whose session lifecycle began. - `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L195) ### agent/status @@ -321,7 +321,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no - `agent` — the agent whose status flipped. - `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L172) ### agent/step-result @@ -348,7 +348,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va - `step` — the step that produced the message. - `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L256) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L259) ### agent/turn-continuation @@ -373,7 +373,7 @@ Override whether the turn continues. The default continues after tool calls or s - `turn` — the turn being continued or stopped. - `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L292) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L295) ### agent/turn-stop @@ -397,7 +397,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a - `agent` — the agent whose composed continuation outcome may be stopped. - `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L302) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L305) ## agent-loop/* From 7ffac135a6e9fa7ae1f96a2a26c82eea6c3e4e0f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 14:51:38 +0800 Subject: [PATCH 24/28] review fix: disambiguate steering checkpoint timing --- .../simplification/2026-07-17-one-send-one-turn.i18n.yaml | 2 +- .../simplification/2026-07-17-one-send-one-turn.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 46ee3998c3..da3da47302 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: f28c93929045b3fcb0f2df8ff3c33adfb8a308ae +2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index f28c939290..dde1530a4e 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -18,7 +18,7 @@ Status: implemented 提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点、请求或 continuation 决策之前记录该消息。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 ## 曾考虑的替代方案 From 99ce2ce8b43c62612a49697c72aa8fc18352736f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 15:25:47 +0800 Subject: [PATCH 25/28] fix duplicate documentation homepage content --- ...026-07-13-documentation-site-projection.md | 2 ++ scripts/project-doc-site.spec.ts | 32 ++++++++++++++++++- scripts/project-doc-site.ts | 22 ++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index 1ddd8fabde..b3bdbb0c1b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -14,6 +14,8 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g `scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. +Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. + The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. Mermaid renders the canonical diagrams. The website workspace explicitly declares the five packages that `vitepress-plugin-mermaid` asks Vite to prebundle because pnpm's strict dependency isolation otherwise makes those transitive packages unavailable to the local development server; Knip records this runtime-only use as an intentional dependency exception. diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 19417d5d99..7378152ac5 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { docsPages, type DocsPage } from '../website/docs.ts' -import { addProjectionFrontmatter, rewriteMarkdown } from './project-doc-site.ts' +import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts' const roots: string[] = [] @@ -175,3 +175,33 @@ describe('addProjectionFrontmatter', () => { ) }) }) + +describe('projectedPageContent', () => { + const page = (sidebar: DocsPage['sidebar']): DocsPage => ({ + locale: 'root', + contentLocale: 'zh-CN', + source: 'docs/index.zh.md', + route: 'index.md', + label: 'Home', + sidebar, + section: 'Home', + order: 0, + }) + + it('omits the source-only body from locale home pages', () => { + expect(projectedPageContent( + '---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n', + page(null), + )).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n') + }) + + it('keeps the full body for ordinary pages', () => { + const markdown = '---\ntitle: Guide\n---\n\n# Guide\n' + expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown) + }) + + it('rejects a locale home source without frontmatter', () => { + expect(() => projectedPageContent('# Harness\n', page(null))) + .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter') + }) +}) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 5c43e6f46a..8d68aac7a6 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -268,6 +268,26 @@ export function addProjectionFrontmatter(markdown: string, sourcePath: string): return `---\n${field}\n---\n\n${markdown}` } +/** + * Select the Markdown rendered for one published page. + * + * @param markdown Rewritten canonical Markdown content. + * @param page Publication manifest entry for the content. + * @returns Full Markdown for ordinary pages or frontmatter-only Markdown for a locale home page. + */ +export function projectedPageContent(markdown: string, page: DocsPage): string { + if (page.sidebar !== null) return markdown + if (!markdown.startsWith('---\n')) { + throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} must start with YAML frontmatter.`) + } + const closingDelimiter = '\n---\n' + const closing = markdown.indexOf(closingDelimiter, 4) + if (closing === -1) { + throw new Error(`project-doc-site: locale home source ${JSON.stringify(page.source)} has unclosed YAML frontmatter.`) + } + return markdown.slice(0, closing + closingDelimiter.length) +} + /** Canonical Markdown files watched by the local VitePress dev server. */ export function docsSourceFiles(): string[] { return [...new Set(docsPages.map(page => resolve(root, page.source)))] @@ -297,6 +317,6 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, }) - writeFileSync(output, addProjectionFrontmatter(projected, page.source)) + writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source)) } } From f899b857a613ba7e789f51cd62e2ee5db3c0370f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 20 Jul 2026 15:48:42 +0800 Subject: [PATCH 26/28] review fix: explain one-send turns plainly --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +-- .../2026-07-17-one-send-one-turn.md | 34 ++++++++++-------- .../2026-07-17-one-send-one-turn.zh.md | 36 +++++++++++-------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index da3da47302..e441d0bfb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-one-send-one-turn.md: 7eec39e1e5b4678c9d454260f326929f4beff6c9 -2026-07-17-one-send-one-turn.zh.md: dde1530a4e6276769ad0ea00ba7af6aa5714520d +2026-07-17-one-send-one-turn.md: 86c056b53700d0e0c02e04a99cf044fb311f5840 +2026-07-17-one-send-one-turn.zh.md: 3ef9973480481d11d1183760c9fc1f3c247629f4 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 7eec39e1e5..86c056b537 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -6,34 +6,40 @@ English | [中文](2026-07-17-one-send-one-turn.zh.md) ## Problem -An ordinary `Agent.send()` payload is one complete caller message. Opportunistically draining every waiting payload into one turn would make adjacent calls share a boundary according to driver timing: calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though callers used the same API. +Suppose a caller submits message A and then message B with two `Agent.send()` calls. Implicit batching can put A and B in one turn simply because both are waiting when the driver reads its queue. The caller made two calls, but the loop silently turns them into one unit of work. -An ordinary turn contains prompt admission, `turn/start`, `turn/end`, and the durability checkpoint. Combining messages would let a later ordinary message join an earlier message's model request instead of observing the earlier ordinary turn's closed result in the same session log, while mixed allowed and blocked prompts would require lifecycle states no caller explicitly requested. +That grouping depends on timing rather than caller intent. Calls from one synchronous stack, neighboring microtasks, event listeners, and model callbacks could be grouped differently even though every caller used the same API. -`steer()` already selects steering semantics while the agent driver is running, while `inject()` records model-facing context without acting as an ordinary message. Implicit ordinary-send batching would make `send()` overlap both explicit operations instead of preserving a single meaning. +This grouping changes behavior, not just the number of model calls. One ordinary turn owns prompt admission, `turn/start`, `turn/end`, and a durability checkpoint. If message B shares message A's turn, B can enter A's model request instead of first seeing A's closed result in the session log. Allowing one message while blocking another also requires a mixed state that no caller requested. ## Decision -Each successful `send()` synchronously validates agent state, snapshots and freezes content, appends one independent FIFO item, and publishes `agent/queued`. The loop dequeues at most one ordinary item for each turn start. If two ordinary items both reach turn processing, the second ordinary turn starts only after the first ordinary turn ends and its durability checkpoint settles; broad cancellation, disposal, or a pre-start failure can discard an unstarted item without creating an empty turn. +The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Prompt admission decides one message. An allowed prompt becomes that turn's `user/message`; a blocked prompt appends one durable `prompt/blocked` and ends that one-message turn as `rejected`. There are no mixed-batch or all-blocked-batch branches. +Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. -Running `steer()` adds to the steering FIFO. An open turn records it at the next steering checkpoint before a request or continuation decision. Steering can make continuation default to another step, but continuation or terminal policy can still stop before that step begins. After turn close and its durability checkpoint, remaining steering becomes later queued input. Terminal `agent/turn-stop`, cancellation, or disposal may discard it. Idle `steer()` delegates to `send()` and therefore creates an independent ordinary queue item. `inject()` retains its turn-enclosure and flush behavior. `cancel()`, `status`, and `whenIdle()` remain whole-agent operations rather than per-message controls. +If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. + +Prompt admission decides one message at a time. An allowed prompt becomes that turn's `user/message`; a blocked prompt records one durable `prompt/blocked` and closes its one-message turn as `rejected`. Mixed-batch and all-blocked-batch branches do not exist. + +The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in a separate steering FIFO. While a turn remains open, the loop records that input at the next steering checkpoint, which comes before either a model request or the decision whether to continue. Steering makes another step the default, but continuation or terminal policy can still stop before the step starts. Steering left after the turn closes and its durability checkpoint settles becomes later queued input; terminal `agent/turn-stop`, cancellation, or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item. + +`inject()` continues to add model-facing context without submitting an ordinary message; its existing turn-enclosure and flush behavior stays unchanged. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, including turn close and its checkpoint, so `running` does not prove that a turn is open. ## Alternatives considered -**Keep opportunistic ordinary-send batching for throughput.** Combining queued ordinary prompts can reduce model calls when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later ordinary message run before the preceding ordinary turn closes and its checkpoint settles. Explicit lifecycle semantics are worth the additional model calls; any future ordinary-send batching feature needs an explicit caller-visible contract justified by measurements. +**Keep automatic ordinary-send batching to reduce model calls.** This can improve throughput when producers outpace the driver, but it makes turn boundaries depend on scheduling and lets a later message run before the preceding turn closes and reaches its checkpoint. The decision keeps the predictable boundary and accepts the extra calls. Any future batching feature needs an explicit caller-visible contract backed by measurements. ## Verification -- Unit and property coverage pins same-stack, neighboring-microtask, differently sourced, and reentrant sends as one FIFO-ordered message per turn. -- A real-composition test pipes two lines through the built stdio binary and observes two model requests and two turn boundaries. -- A deferred first ordinary-turn flush proves the next queued ordinary turn cannot start before the checkpoint settles and that its request sees the preceding assistant result; a rejected flush still settles before the next ordinary turn starts. -- Prompt veto and listener failure, broad cancellation, disposal, and pre-commit `turn/start` failure preserve balanced recorded turns and do not merge or strand surviving queued work. -- Open-turn, post-turn-close, and idle `steer()`, `inject()`, whole-agent status, and `whenIdle()` retain their existing coverage. +- Unit and property tests submit sends from the same stack, neighboring microtasks, different producers, and reentrant callbacks; every message gets its own FIFO-ordered turn. +- A built-stdio test submits two lines and observes two model requests and two turn boundaries. +- Delayed and rejected first-turn checkpoints keep the next turn waiting and prove that its request sees the preceding assistant result. +- Failure-path tests cover prompt veto, listener failure, broad cancellation, disposal, and failure before `turn/start`; recorded turns stay balanced, messages do not merge, and surviving queued work still drains. +- Separate tests cover open-turn, post-turn-close, and idle `steer()`, plus `inject()`, whole-agent status, and `whenIdle()`. ## Consequences -Ordinary turn boundaries are deterministic, and a FIFO successor that reaches turn processing observes the preceding completed ordinary turn's closed session result after that turn's checkpoint settles; settlement does not mean a failed flush became durable. Several queued items can still run under one global `running` interval, which can also cover turn close and its checkpoint, so `running` does not prove a turn is open. Broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations rather than per-message results. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion or cancellation handle; broad cancellation can discard the entire unstarted tail, while status and quiescence remain agent-wide observations. -Workloads that relied on coincidental ordinary-send batching make more model requests, incur more checkpoints, and may take longer to drain; FIFO queues may grow under sustained producers. Ordinary-send batching can return only through an explicit measured contract. +The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index dde1530a4e..3ef9973480 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 移除普通 send 的隐式批处理 +# Agent Note: 删除普通 send 的隐式批处理 Status: implemented @@ -6,34 +6,40 @@ Status: implemented ## 问题 -每次普通 `Agent.send()` 接受的载荷都是一条完整的调用方消息。如果机会式地把所有待处理载荷放入同一个轮次,相邻调用是否共享边界就会取决于驱动器的运行时机:即使调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 +假设调用方连续两次调用 `Agent.send()`,先提交消息 A,再提交消息 B。隐式批处理可能只因为驱动器读取队列时两条消息都在等待,就把 A、B 放进同一个轮次。调用方明明调用了两次,agent loop(智能体循环)却悄悄把它们变成一个工作单元。 -普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。合并消息会让后一条普通消息加入前一条普通消息的模型请求,无法观察同一会话日志中前一个已关闭普通轮次的结果;获准与被阻止提示词的混合还会引入调用方从未显式请求的生命周期状态。 +这种分组取决于运行时机,而不是调用方的意图。因此,即使所有调用方使用相同 API,来自同一个同步调用栈、相邻微任务、事件监听器和模型回调的调用也可能产生不同分组。 -`steer()` 已经用于在 agent(智能体)驱动器运行时选择 steering(中途引导)语义,`inject()` 则记录面向模型的上下文而不充当普通消息。普通 send 的隐式批处理会让 `send()` 与这两种显式操作产生语义重叠,无法保持单一含义。 +这种分组改变的不只是模型调用次数。一个普通轮次包含提示词准入、`turn/start`、`turn/end` 和持久性检查点。如果消息 B 与消息 A 共用轮次,B 可能直接进入 A 的模型请求,而不是先看到 A 在会话日志中已经关闭的结果。若系统允许一条消息、阻止另一条消息,还需要引入调用方没有请求的混合状态。 ## 决策 -每次成功的 `send()` 都会同步校验 agent(智能体)状态、创建并冻结内容快照、追加一个独立的 FIFO 队列项,然后发布 `agent/queued`。agent loop(智能体循环)在每个轮次开始时最多取出一个普通队列项。如果两个普通队列项最终都进入轮次处理,第二个普通轮次只能在第一个普通轮次结束且其持久性检查点处理结束后开始;广义取消、dispose(资源释放)或启动前失败可以丢弃尚未启动的队列项,而不创建空轮次。 +规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -提示词准入只处理一条消息。获准提示词成为该轮次的 `user/message`;被阻止提示词追加一条持久的 `prompt/blocked`,并让这个单消息轮次以 `rejected` 结束。实现中没有混合批次或全阻止批次分支。 +队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 -运行中的 `steer()` 会把消息加入 steering(中途引导)FIFO。打开的轮次会在下一个 steering 检查点记录该消息;该检查点发生在请求或 continuation 决策之前。Steering 可以让默认 continuation 决策进入下一步骤,但 continuation 或终止策略仍可在该步骤开始前停止轮次。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入。终止性的 `agent/turn-stop`、取消或 dispose(资源释放)可能丢弃该消息。空闲时的 `steer()` 委托给 `send()`,因此创建一个独立的普通队列项。`inject()` 保持现有的轮次封闭与持久化刷新行为。`cancel()`、`status` 和 `whenIdle()` 仍是面向整个 agent 的操作,不变成逐消息控制。 +如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 + +提示词准入每次只决定一条消息。获准提示词成为该轮次的 `user/message`;被阻止的提示词记录一条持久的 `prompt/blocked`,并让自己的单消息轮次以 `rejected` 关闭。实现中不存在混合批次或全阻止批次分支。 + +上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入独立的 steering(中途引导)FIFO。只要当前轮次仍然打开,agent loop 就会在下一个 steering 检查点记录该输入;该检查点位于模型请求或继续轮次的决策之前。收到 steering 会把再执行一步作为默认选择,但继续轮次的策略或终止策略仍可在该步骤开始前停止。轮次关闭且其持久性检查点处理结束后,剩余的 steering 会成为后续排队输入;终止性的 `agent/turn-stop`、取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。 + +`inject()` 继续添加面向模型的上下文,而不提交普通消息;其现有的轮次封闭与持久化刷新行为保持不变。`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status` 和 `whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,该区间还可能覆盖轮次关闭及其检查点,因此 `running` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 -**为吞吐量保留普通 send 的机会式批处理。** 当消息进入队列的速度超过驱动器的处理速度时,合并排队的普通提示词可以减少模型调用,但会让轮次边界取决于调度,并让后一条普通消息在前一个普通轮次关闭且其检查点处理结束之前就运行。额外模型调用的代价低于显式生命周期语义的价值;未来的任何普通 send 批处理功能都必须提供调用方可见的显式契约,并由测量结果证明其必要性。 +**保留普通 send 的自动批处理,以减少模型调用。** 当消息进入队列的速度超过驱动器的处理速度时,这种做法可以提高吞吐量,但会让轮次边界取决于调度,并让后一条消息在前一轮关闭且到达检查点之前运行。本决策保留可预测的边界,并接受额外调用。未来若要加入批处理功能,必须提供调用方可见的显式契约,并有测量结果作为依据。 ## 验证 -- 单元与性质覆盖固定了同一调用栈、相邻微任务、不同来源和重入 send 的行为:每个轮次只有一条消息,并按 FIFO 排序。 -- 真实组合测试会通过 stdio 构建产物同时写入两行,并观察两个模型请求和两个轮次边界。 -- 延迟第一个普通轮次的持久化刷新可以证明下一个排队的普通轮次不能在检查点处理结束前开始,且其请求能看到前一条助手结果;刷新即使失败,下一个普通轮次也要等它结束后才会开始。 -- 提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 提交前失败都会保持已记录轮次边界平衡,不会合并消息或让仍应处理的排队工作滞留。 -- 轮次打开时、轮次关闭后与空闲时的 `steer()`、`inject()`、面向整个 agent 的状态和 `whenIdle()` 保持原有覆盖。 +- 单元测试和性质测试从同一调用栈、相邻微任务、不同生产方和重入回调提交 send;每条消息都会得到一个按 FIFO 排序的独立轮次。 +- stdio 构建产物测试提交两行输入,并观察到两个模型请求和两个轮次边界。 +- 延迟和拒绝第一个轮次的检查点,都能让下一个轮次保持等待,并证明其请求可以看到前一条助手结果。 +- 失败路径测试覆盖提示词否决、监听器失败、广义取消、dispose 和 `turn/start` 之前的失败;已记录的轮次保持边界平衡,消息不会合并,仍需处理的排队工作也能继续清空。 +- 其他测试分别覆盖轮次打开时、轮次关闭后和空闲时的 `steer()`,以及 `inject()`、面向整个 agent 的状态和 `whenIdle()`。 ## 后果 -普通轮次边界是确定的;FIFO 后继项进入轮次处理时,会观察前一个已完成普通轮次在会话中已关闭的结果;检查点处理结束不表示失败的持久化刷新已经成功。多个排队项仍可在同一个全局 `running` 区间内执行,该区间也可以覆盖轮次关闭及其检查点,因此 `running` 不表示轮次必然仍然打开。广义取消可以丢弃整个未启动队尾,状态和静止性仍是面向整个 agent 的观察,而不是逐消息结果。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄;广义取消可以丢弃整个尚未启动的队尾,状态和静止性也仍是面向整个 agent 的观察。 -依赖普通 send 偶然批处理的工作负载会产生更多模型请求和检查点,队列清空时间也可能延长;持续有消息进入时,FIFO 队列还可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 +代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 From 2b1b59846751381322867e997ddb2024d001923e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 16:32:08 +0800 Subject: [PATCH 27/28] docs: restore generated Cordis core API --- ...-07-20-generated-cordis-core-api.i18n.yaml | 6 + .../2026-07-20-generated-cordis-core-api.md | 31 ++ ...2026-07-20-generated-cordis-core-api.zh.md | 31 ++ docs/AGENTS.md | 2 +- docs/cordis-catalog/core/context.md | 364 +++++++++++++++ docs/cordis-catalog/core/events.md | 207 +++++++++ docs/cordis-catalog/core/fiber.md | 375 +++++++++++++++ docs/cordis-catalog/core/registry.md | 152 ++++++ docs/cordis-catalog/core/service.md | 102 +++++ docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- scripts/cordis-core-api.spec.ts | 49 ++ scripts/cordis-core-api.ts | 433 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 20 +- scripts/project-doc-site.spec.ts | 12 + website/.vitepress/config.ts | 2 + website/docs.ts | 15 + 17 files changed, 1795 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md create mode 100644 .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md create mode 100644 docs/cordis-catalog/core/context.md create mode 100644 docs/cordis-catalog/core/events.md create mode 100644 docs/cordis-catalog/core/fiber.md create mode 100644 docs/cordis-catalog/core/registry.md create mode 100644 docs/cordis-catalog/core/service.md create mode 100644 scripts/cordis-core-api.spec.ts create mode 100644 scripts/cordis-core-api.ts diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml new file mode 100644 index 0000000000..6bae3b4d87 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-20-generated-cordis-core-api.md: 848dec2dba6f432c706798c40abe98e8937da651 +2026-07-20-generated-cordis-core-api.zh.md: c40a480224f4e1387b71ade9264458cd84403584 diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md new file mode 100644 index 0000000000..848dec2dba --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md @@ -0,0 +1,31 @@ +# Agent Note: Generate the Cordis core API reference + +Status: implemented + +English | [中文](2026-07-20-generated-cordis-core-api.zh.md) + +## Problem + +Plugin authors need the detailed Cordis APIs behind `ctx`, event dispatch, fibers, plugin registration, and services. The generated [Harness event and service catalogs](2026-06-20-generated-cordis-catalog.md) intentionally summarize inherited Cordis members, so they do not replace a method-level Cordis reference. Keeping a second hand-written copy under the website would drift from the vendored source and make the renderer an additional documentation owner. + +## Decision + +`scripts/cordis-core-api.ts` reads the public declarations and original JSDoc from `vendor/cordis/src` with the TypeScript compiler API. An explicit page manifest generates five files under [`docs/cordis-catalog/core/`](../../../../docs/cordis-catalog/core/context.md): Context, Events, Fiber, Registry, and Service. `scripts/gen-cordis-catalog.ts` writes these pages together with the Harness event and service catalogs, and `verify-cordis-catalog` rejects stale output. + +The generator validates that documented classes and methods retain descriptive JSDoc, including parameter and non-void return contracts. It emits declaration-only `ts cordis-catalog` fences with the original JSDoc, then renders the same description, parameters, and return contract as readable Markdown. Source links point to the vendored files, and the five pages cross-link to one another. The Harness catalogs remain the exhaustive inventory of repository-declared events and `ctx.*` services; the core pages document how the inherited Cordis APIs operate. + +`website/docs.ts` publishes the five canonical files under matching `/reference/cordis-api/` and `/en/reference/cordis-api/` routes. Both locales use the English generated source until the generator emits translated pages, so changing language preserves navigation structure and route identity. + +## Alternatives considered + +**Restore the old website files as canonical Markdown.** This would recover the pages quickly, but their signatures and prose could drift from the vendored implementation and the website would regain a second documentation source. + +**Expand the inherited tier of the Harness catalogs in place.** Those catalogs answer which Harness events and services exist. Mixing full framework class references into the same pages would obscure that inventory and reverse their deliberate terse inherited tier. + +**Publish vendored source declarations directly.** Source files are authoritative but do not provide stable topic pages, curated public ordering, or website navigation, and they expose implementation bodies that are not part of the reference contract. + +## Consequences + +The five Cordis API pages follow vendor updates through one deterministic generator and share the repository's documentation freshness gate. The website gains a dedicated Cordis API section without copied site content, while root and English navigation remain structurally identical. + +The page manifest is curated, so a newly public Cordis core type needs an explicit generator entry. Generated prose is English-only, and source JSDoc quality directly limits reference quality; Chinese output requires generator-level translation rather than hand-editing the generated files. diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md new file mode 100644 index 0000000000..c40a480224 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 生成 Cordis 核心 API 参考文档 + +Status: implemented + +[English](2026-07-20-generated-cordis-core-api.md) | 中文 + +## 问题 + +插件作者需要了解 `ctx`、事件派发、Fiber、插件注册和 Service 背后的详细 Cordis API。已有的 [Harness 事件与服务目录](2026-06-20-generated-cordis-catalog.md)有意只简要概括继承自 Cordis 的成员,因此无法替代方法级 Cordis 参考文档。如果在网站下维护另一份手写副本,它会与 vendored 源码产生漂移,也会让渲染器成为额外的文档所有者。 + +## 决策 + +`scripts/cordis-core-api.ts` 使用 TypeScript Compiler API,从 `vendor/cordis/src` 读取公开声明和原始 JSDoc。一个显式页面清单在 [`docs/cordis-catalog/core/`](../../../../docs/cordis-catalog/core/context.md) 下生成五个文件:Context、Events、Fiber、Registry 和 Service。`scripts/gen-cordis-catalog.ts` 将这些页面与 Harness 事件和服务目录一同写入,`verify-cordis-catalog` 会拒绝过期产物。 + +生成器会验证所记录的类和方法保留描述性 JSDoc,包括参数和非 void 返回值契约。它生成包含原始 JSDoc 且仅含声明的 `ts cordis-catalog` 代码围栏,再将同一份说明、参数和返回值契约渲染为便于阅读的 Markdown。源码链接指向 vendored 文件,五个页面之间相互交叉链接。Harness 目录仍是仓库声明的事件与 `ctx.*` 服务的完整清单;核心页面负责说明继承自 Cordis 的 API 如何工作。 + +`website/docs.ts` 将五个规范源文件发布到结构对应的 `/reference/cordis-api/` 和 `/en/reference/cordis-api/` 路由。在生成器产出翻译页面之前,两个 locale 都使用英文生成源,因此切换语言时导航结构和路由标识保持不变。 + +## 考虑过的替代方案 + +**将旧网站文件恢复为规范 Markdown。** 这能快速恢复页面,但其签名和说明可能与 vendored 实现漂移,网站也会重新成为第二个文档来源。 + +**直接扩充 Harness 目录中的继承层。** 这些目录回答有哪些 Harness 事件与服务。将完整的框架类参考混入同一页面会模糊这份清单的定位,并推翻继承层保持精简的既有决定。 + +**直接发布 vendored 源码声明。** 源文件具有权威性,但不能提供稳定的主题页面、经过筛选的公开顺序或网站导航,还会暴露不属于参考契约的实现体。 + +## 影响 + +五个 Cordis API 页面通过同一个确定性生成器跟随 vendor 更新,并复用仓库的文档新鲜度检查。网站无需复制内容即可获得独立的 Cordis API 章节,中文入口和英文入口的导航结构保持一致。 + +页面清单需要人工维护,因此新增公开 Cordis 核心类型时必须显式添加生成器条目。当前生成说明只有英文,且源码 JSDoc 的质量直接决定参考文档质量;中文产物需要在生成器层实现翻译,不能手工编辑生成文件。 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index f33110d9b0..da0c03b088 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -18,7 +18,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history | | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts | -| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | +| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [Cordis core API](cordis-catalog/core/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) | Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. diff --git a/docs/cordis-catalog/core/context.md b/docs/cordis-catalog/core/context.md new file mode 100644 index 0000000000..f6b249c738 --- /dev/null +++ b/docs/cordis-catalog/core/context.md @@ -0,0 +1,364 @@ + + +# Context + +The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md). + +Root and child dependency containers for Cordis plugins. + +A context is a proxy: normal property reads go through the service resolver, while `extend()`, `isolate()`, and `intercept()` create scoped child contexts without mutating their parent. + +[Source](../../../vendor/cordis/src/context.ts#L42) + +### ctx.extend(meta?) + +```ts cordis-catalog +/** + * Create a child context with extra metadata on top of the current scope. + * + * The child prototypally inherits every property of this context; own + * properties of `meta` shadow the inherited ones. The parent is not mutated. + * + * @param meta — own properties (including symbol keys) to define on the child. + * @returns a child context inheriting from this one. + */ +extend(meta = {}): this +``` + +Create a child context with extra metadata on top of the current scope. + +The child prototypally inherits every property of this context; own properties of `meta` shadow the inherited ones. The parent is not mutated. + +- `meta` — own properties (including symbol keys) to define on the child. + +**Returns** a child context inheriting from this one. + +[Source](../../../vendor/cordis/src/context.ts#L99) + +### ctx.isolate(name, label?) + +```ts cordis-catalog +/** + * Create a child context with an independent service scope for `name`. + * + * Below the returned context, reads and writes of the service `name` + * resolve against the new label instead of the parent's, so a different + * implementation can be provided without affecting the parent scope. + * Passing the same `label` to two `isolate()` calls joins their scopes. + * + * @param name — the service name to isolate. + * @param label — scope label to join; defaults to a fresh unique symbol. + * @returns a child context whose `name` service resolves in the new scope. + */ +isolate(name: string, label?: symbol) +``` + +Create a child context with an independent service scope for `name`. + +Below the returned context, reads and writes of the service `name` resolve against the new label instead of the parent's, so a different implementation can be provided without affecting the parent scope. Passing the same `label` to two `isolate()` calls joins their scopes. + +- `name` — the service name to isolate. +- `label` — scope label to join; defaults to a fresh unique symbol. + +**Returns** a child context whose `name` service resolves in the new scope. + +[Source](../../../vendor/cordis/src/context.ts#L121) + +### ctx.intercept(name, config) + +```ts cordis-catalog +/** + * Add service-specific intercept config for plugins started below this + * context. + * + * Plugins loaded under the returned context see `config` merged into the + * service's resolved config (ancestor entries first; see + * `Service[symbols.resolveConfig]`). The parent context is not affected. + * + * @param name — the service name whose config to intercept. + * @param config — the intercept config to merge for that service. + * @returns a child context carrying the additional intercept entry. + */ +intercept(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this +intercept(name: string, config: any): this +``` + +Add service-specific intercept config for plugins started below this context. + +Plugins loaded under the returned context see `config` merged into the service's resolved config (ancestor entries first; see `Service[symbols.resolveConfig]`). The parent context is not affected. + +- `name` — the service name whose config to intercept. +- `config` — the intercept config to merge for that service. + +**Returns** a child context carrying the additional intercept entry. + +[Source](../../../vendor/cordis/src/context.ts#L139) + +### ctx.root + +```ts cordis-catalog +/** The root context of the application (every child context shares it). @experimental */ +root: this +``` + +The root context of the application (every child context shares it). @experimental + +[Source](../../../vendor/cordis/src/context.ts#L22) + +### ctx.baseUrl + +```ts cordis-catalog +/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ +baseUrl?: string +``` + +Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. + +[Source](../../../vendor/cordis/src/context.ts#L24) + +### ctx.events + +```ts cordis-catalog +/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */ +events: EventsService +``` + +The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). + +[Source](../../../vendor/cordis/src/context.ts#L26) + +### ctx.logger + +```ts cordis-catalog +/** The logging service. Call `ctx.logger(name)` for a named logger. */ +logger: LoggerService +``` + +The logging service. Call `ctx.logger(name)` for a named logger. + +[Source](../../../vendor/cordis/src/context.ts#L28) + +### ctx.reflect + +```ts cordis-catalog +/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */ +reflect: ReflectService +``` + +The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). + +[Source](../../../vendor/cordis/src/context.ts#L30) + +### ctx.registry + +```ts cordis-catalog +/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */ +registry: RegistryService +``` + +The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). + +[Source](../../../vendor/cordis/src/context.ts#L32) + +## Static members + +### Context.effect + +```ts cordis-catalog +/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */ +static readonly effect: unique symbol +``` + +Symbol key under which a disposer exposes its EffectMeta diagnostics tree. + +[Source](../../../vendor/cordis/src/context.ts#L44) + +### Context.filter + +```ts cordis-catalog +/** Symbol key for a context's listener filter, consulted on every event dispatch. */ +static readonly filter: unique symbol +``` + +Symbol key for a context's listener filter, consulted on every event dispatch. + +[Source](../../../vendor/cordis/src/context.ts#L46) + +### Context.isolate + +```ts cordis-catalog +/** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */ +static readonly isolate: unique symbol +``` + +Symbol key of the isolation map (see the `Context[symbols.isolate]` property). + +[Source](../../../vendor/cordis/src/context.ts#L48) + +### Context.intercept + +```ts cordis-catalog +/** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */ +static readonly intercept: unique symbol +``` + +Symbol key of the intercept map (see the `Context[symbols.intercept]` property). + +[Source](../../../vendor/cordis/src/context.ts#L50) + +### Context.is(value) + +```ts cordis-catalog +/** + * Returns true for Cordis context proxies and context prototypes. + * + * Works across realms and across multiple copies of cordis, because the + * brand is keyed by a global symbol rather than by `instanceof`. + * + * @param value — the value to test. + * @returns `true` if `value` is a Cordis context, narrowing its type. + */ +static is(value: any): value is Context +``` + +Returns true for Cordis context proxies and context prototypes. + +Works across realms and across multiple copies of cordis, because the brand is keyed by a global symbol rather than by `instanceof`. + +- `value` — the value to test. + +**Returns** `true` if `value` is a Cordis context, narrowing its type. + +[Source](../../../vendor/cordis/src/context.ts#L61) + +## Service store and mixins + +### ctx.get(name, strict?) + +```ts cordis-catalog +/** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true` (default), only return implementations + * whose providing fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ +get(name: K, strict?: boolean): undefined | this[K] +get(name: string, strict?: boolean): any +``` + +Read a service from the store without the inject requirement. + +- `name` — the service name. +- `strict` — when `true` (default), only return implementations whose providing fiber is currently active. + +**Returns** the service value, or `undefined` when not (yet) provided. + +[Source](../../../vendor/cordis/src/reflect.ts#L16) + +### ctx.set(name, value) + +```ts cordis-catalog +/** + * Overwrite a provided service's value. + * + * Only the fiber that provided the service may set it; setting an + * unprovided name throws. + * + * @param name — the service name. + * @param value — the new service value. + */ +set(name: K, value: undefined | this[K]): void +set(name: string, value: any): void +``` + +Overwrite a provided service's value. + +Only the fiber that provided the service may set it; setting an unprovided name throws. + +- `name` — the service name. +- `value` — the new service value. + +[Source](../../../vendor/cordis/src/reflect.ts#L28) + +### ctx.provide(name, value) + +```ts cordis-catalog +/** + * Register a service implementation owned by the current fiber. + * + * The service becomes visible to dependents in the same isolation scope + * once the fiber is active; it is unregistered (waking dependents) when + * the returned disposer runs or the fiber unloads. Throws if the name is + * already provided in this scope or declared as an accessor. + * + * @param name — the service name. + * @param value — the service value. + * @returns a disposer that unregisters the service. + */ +provide(name: K, value: undefined | this[K]): () => void +provide(name: string, value?: any): () => void +``` + +Register a service implementation owned by the current fiber. + +The service becomes visible to dependents in the same isolation scope once the fiber is active; it is unregistered (waking dependents) when the returned disposer runs or the fiber unloads. Throws if the name is already provided in this scope or declared as an accessor. + +- `name` — the service name. +- `value` — the service value. + +**Returns** a disposer that unregisters the service. + +[Source](../../../vendor/cordis/src/reflect.ts#L43) + +### ctx.accessor(name, options) + +```ts cordis-catalog +/** + * Define a computed context property backed by get/set hooks. + * + * The accessor is removed when the current fiber unloads. Throws if the + * name is already declared. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + */ +accessor(name: string, options: Omit): void +``` + +Define a computed context property backed by get/set hooks. + +The accessor is removed when the current fiber unloads. Throws if the name is already declared. + +- `name` — the context property name. +- `options` — the `get` hook and optional `set` hook. + +[Source](../../../vendor/cordis/src/reflect.ts#L55) + +### ctx.mixin(name, mixins) + +```ts cordis-catalog +/** + * Expose selected members of a service directly on `ctx`. + * + * Each mixed-in key becomes an accessor that forwards to the service + * (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. + * Mixins are removed when the current fiber unloads. + * + * @param name — the context property holding the source service. + * @param mixins — keys to forward, or a source-key → ctx-key map. + */ +mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void +mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): void +``` + +Expose selected members of a service directly on `ctx`. + +Each mixed-in key becomes an accessor that forwards to the service (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. Mixins are removed when the current fiber unloads. + +- `name` — the context property holding the source service. +- `mixins` — keys to forward, or a source-key → ctx-key map. + +[Source](../../../vendor/cordis/src/reflect.ts#L66) diff --git a/docs/cordis-catalog/core/events.md b/docs/cordis-catalog/core/events.md new file mode 100644 index 0000000000..2fb64e78a2 --- /dev/null +++ b/docs/cordis-catalog/core/events.md @@ -0,0 +1,207 @@ + + +# Events + +The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md). + +### ctx.parallel(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, running all listeners concurrently. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + * @returns a promise resolving once every listener has settled. + */ +parallel(name: K, ...args: Parameters): Promise +parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise +``` + +Dispatch an event, running all listeners concurrently. + +- `name` — the event name. +- `args` — arguments passed to every listener. + +**Returns** a promise resolving once every listener has settled. + +[Source](../../../vendor/cordis/src/events.ts#L43) + +### ctx.emit(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event synchronously, ignoring listener return values. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + */ +emit(name: K, ...args: Parameters): void +emit(thisArg: NoInfer>, name: K, ...args: Parameters): void +``` + +Dispatch an event synchronously, ignoring listener return values. + +- `name` — the event name. +- `args` — arguments passed to every listener. + +[Source](../../../vendor/cordis/src/events.ts#L52) + +### ctx.serial(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, awaiting listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ +serial(name: K, ...args: Parameters): Promisify> +serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> +``` + +Dispatch an event, awaiting listeners in order until one bails. + +- `name` — the event name. +- `args` — arguments passed to each listener. + +**Returns** the first bail value (non-null, non-false, non-undefined), if any. + +[Source](../../../vendor/cordis/src/events.ts#L62) + +### ctx.bail(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event, calling listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ +bail(name: K, ...args: Parameters): ReturnType +bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` + +Dispatch an event, calling listeners in order until one bails. + +- `name` — the event name. +- `args` — arguments passed to each listener. + +**Returns** the first bail value (non-null, non-false, non-undefined), if any. + +[Source](../../../vendor/cordis/src/events.ts#L72) + +### ctx.waterfall(name, ...args) + +```ts cordis-catalog +/** + * Dispatch an event whose last argument is a `next` continuation. + * + * Each listener wraps the rest of the chain: calling `next()` invokes the + * next listener (finally the built-in behavior); not calling it vetoes. + * + * @param name — the event name. + * @param args — listener arguments; the final one is the innermost `next`. + * @returns the outermost listener's return value. + */ +waterfall(name: K, ...args: Parameters): ReturnType +waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType +``` + +Dispatch an event whose last argument is a `next` continuation. + +Each listener wraps the rest of the chain: calling `next()` invokes the next listener (finally the built-in behavior); not calling it vetoes. + +- `name` — the event name. +- `args` — listener arguments; the final one is the innermost `next`. + +**Returns** the outermost listener's return value. + +[Source](../../../vendor/cordis/src/events.ts#L85) + +### ctx.on(name, listener, options?) + +```ts cordis-catalog +/** + * Register an event listener owned by the current fiber. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ +on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Register an event listener owned by the current fiber. + +- `name` — the event name to listen for. +- `listener` — called with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](../../../vendor/cordis/src/events.ts#L96) + +### ctx.once(name, listener, options?) + +```ts cordis-catalog +/** + * Same as `on()`, but the listener disposes itself after its first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ +once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean +``` + +Same as `on()`, but the listener disposes itself after its first call. + +- `name` — the event name to listen for. +- `listener` — called at most once with the dispatch arguments. +- `options` — listener options; a boolean is shorthand for `prepend`. + +**Returns** a disposer removing the listener; `true` if it was still registered. + +[Source](../../../vendor/cordis/src/events.ts#L105) + +## EventOptions + +Options accepted by `ctx.on()` and `ctx.once()`. + +```ts cordis-catalog +/** Options accepted by `ctx.on()` and `ctx.once()`. */ +interface EventOptions { + /** Add the listener before existing listeners for the same event. */ + prepend?: boolean + /** Receive the event regardless of context filter checks. */ + global?: boolean +} +``` + +[Source](../../../vendor/cordis/src/events.ts#L111) + +## DispatchMode + +Event dispatch strategy used by the event service. + +`emit` runs synchronous listeners without awaiting them, `parallel` awaits all listeners together, `serial` awaits them in order until one bails, `bail` stops on the first synchronous bail value, and `waterfall` composes listeners around a final `next` callback. + +```ts cordis-catalog +/** + * Event dispatch strategy used by the event service. + * + * `emit` runs synchronous listeners without awaiting them, `parallel` awaits + * all listeners together, `serial` awaits them in order until one bails, + * `bail` stops on the first synchronous bail value, and `waterfall` composes + * listeners around a final `next` callback. + */ +type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' +``` + +[Source](../../../vendor/cordis/src/events.ts#L31) diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md new file mode 100644 index 0000000000..d865ce01fc --- /dev/null +++ b/docs/cordis-catalog/core/fiber.md @@ -0,0 +1,375 @@ + + +# Fiber + +A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it. + +### ctx.effect(execute, label?) + +```ts cordis-catalog +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ +effect(execute: () => SyncEffect, label?: string): Disposable> +effect(execute: () => Effect, label?: string): AsyncDisposable> +``` + +Register a cleanup-aware effect on this fiber. + +`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape. + +- `execute` — the effect body; see `Effect` for accepted shapes. +- `label` — effect label shown in `getEffects()` diagnostics. + +**Returns** a disposer that tears the effect down and settles once done. + +[Source](../../../vendor/cordis/src/fiber.ts#L419) + +### ctx.fiber + +```ts cordis-catalog +/** The fiber (plugin runtime instance) that owns this context. */ +fiber: Fiber +``` + +The fiber (plugin runtime instance) that owns this context. + +[Source](../../../vendor/cordis/src/fiber.ts#L11) + +## The Fiber class + +Runtime instance of one plugin application. + +A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. + +[Source](../../../vendor/cordis/src/fiber.ts#L183) + +### fiber.uid + +```ts cordis-catalog +/** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ +public uid: number | null +``` + +Unique id within the registry; 0 for the root fiber, `null` once disposed. + +[Source](../../../vendor/cordis/src/fiber.ts#L185) + +### fiber.ctx + +```ts cordis-catalog +/** The context this fiber's plugin runs in (extends the parent context). */ +public readonly ctx: Context +``` + +The context this fiber's plugin runs in (extends the parent context). + +[Source](../../../vendor/cordis/src/fiber.ts#L187) + +### fiber.config + +```ts cordis-catalog +/** The validated plugin config (updated by `update()`). */ +public config: any +``` + +The validated plugin config (updated by `update()`). + +[Source](../../../vendor/cordis/src/fiber.ts#L189) + +### fiber.state + +```ts cordis-catalog +/** Current lifecycle state; transitions emit `internal/status`. */ +public state +``` + +Current lifecycle state; transitions emit `internal/status`. + +[Source](../../../vendor/cordis/src/fiber.ts#L191) + +### fiber.dispose + +```ts cordis-catalog +/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ +public readonly dispose: () => Promise +``` + +Dispose this fiber: unload the plugin, then settle once cleanup finished. + +[Source](../../../vendor/cordis/src/fiber.ts#L193) + +### fiber.store + +```ts cordis-catalog +/** Snapshot of required service implementations while loaded; `undefined` otherwise. */ +public store: Dict | undefined +``` + +Snapshot of required service implementations while loaded; `undefined` otherwise. + +[Source](../../../vendor/cordis/src/fiber.ts#L195) + +### fiber.inertia + +```ts cordis-catalog +/** The in-flight load/unload transition, if one is currently running. */ +public inertia: Promise | undefined +``` + +The in-flight load/unload transition, if one is currently running. + +[Source](../../../vendor/cordis/src/fiber.ts#L197) + +### fiber.name + +```ts cordis-catalog +/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ +get name() +``` + +The plugin's display name, inherited from the nearest named ancestor, else `'root'`. + +[Source](../../../vendor/cordis/src/fiber.ts#L340) + +### fiber.assertActive() + +```ts cordis-catalog +/** + * Throw if the fiber has already been disposed. + * + * @returns nothing when the fiber is still active. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. + */ +assertActive() +``` + +Throw if the fiber has already been disposed. + +**Returns** nothing when the fiber is still active. + +[Source](../../../vendor/cordis/src/fiber.ts#L355) + +### fiber.effect(execute, label?) + +```ts cordis-catalog +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ +effect(execute: () => SyncEffect, label?: string): Disposable> +effect(execute: () => Effect, label?: string): AsyncDisposable> +``` + +Register a cleanup-aware effect on this fiber. + +`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape. + +- `execute` — the effect body; see `Effect` for accepted shapes. +- `label` — effect label shown in `getEffects()` diagnostics. + +**Returns** a disposer that tears the effect down and settles once done. + +[Source](../../../vendor/cordis/src/fiber.ts#L419) + +### fiber.getEffects() + +```ts cordis-catalog +/** + * Return metadata for currently registered effects. + * + * @returns one {@link EffectMeta} tree per labeled live effect. + */ +getEffects() +``` + +Return metadata for currently registered effects. + +**Returns** one `EffectMeta` tree per labeled live effect. + +[Source](../../../vendor/cordis/src/fiber.ts#L572) + +### fiber.await() + +```ts cordis-catalog +/** + * Wait for current lifecycle work and rethrow startup errors. + * + * @returns this fiber, once it has settled into a stable state. + * @throws the config-validation or plugin-startup error, if any. + */ +async await() +``` + +Wait for current lifecycle work and rethrow startup errors. + +**Returns** this fiber, once it has settled into a stable state. + +[Source](../../../vendor/cordis/src/fiber.ts#L701) + +### fiber.restart() + +```ts cordis-catalog +/** + * Dispose and immediately reload this plugin with its current config. + * + * @returns a promise resolving once the reload settled. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. + */ +async restart() +``` + +Dispose and immediately reload this plugin with its current config. + +**Returns** a promise resolving once the reload settled. + +[Source](../../../vendor/cordis/src/fiber.ts#L715) + +### fiber.update(config, noSave?) + +```ts cordis-catalog +/** + * Validate and apply new config, then restart the plugin. + * + * Runs the `internal/update` waterfall first, so update hooks (and HMR) + * can veto or replace the restart. + * + * @param config — the new raw config; validated before anything restarts. + * @param noSave — hint for persistence hooks not to write the change back. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. + */ +update(config: any, noSave = false) +``` + +Validate and apply new config, then restart the plugin. + +Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart. + +- `config` — the new raw config; validated before anything restarts. +- `noSave` — hint for persistence hooks not to write the change back. + +**Returns** nothing; the restart runs behind the `internal/update` waterfall. + +[Source](../../../vendor/cordis/src/fiber.ts#L733) + +## Effect + +Effect body result accepted by `ctx.effect()` and plugin startup. + +Either a single disposer, a promise of one, or a (possibly async) iterable yielding several — generator effects register each yielded disposer as it is produced. + +```ts cordis-catalog +/** + * Effect body result accepted by `ctx.effect()` and plugin startup. + * + * Either a single disposer, a promise of one, or a (possibly async) iterable + * yielding several — generator effects register each yielded disposer as it + * is produced. + */ +type Effect = + | SyncEffect + | AsyncEffect +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L82) + +## Disposable + +Function returned by an effect to release resources during disposal. + +Disposers run in reverse registration order when the owning fiber unloads; they may be async, in which case unloading awaits them. + +```ts cordis-catalog +/** + * Function returned by an effect to release resources during disposal. + * + * Disposers run in reverse registration order when the owning fiber unloads; + * they may be async, in which case unloading awaits them. + */ +type Disposable = () => T +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L73) + +## EffectMeta + +Tree node used to expose nested effect labels for diagnostics. + +```ts cordis-catalog +/** Tree node used to expose nested effect labels for diagnostics. */ +interface EffectMeta { + /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ + label: string + /** Metadata of nested effects registered while this effect ran. */ + children: EffectMeta[] +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L95) + +## CordisError + +Framework error with a stable machine-readable code. + +```ts cordis-catalog +/** Framework error with a stable machine-readable code. */ +class CordisError extends Error { + /** + * @param code — the stable error code; also the default message. + * @param message — optional human-readable override. + */ + constructor(public code: CordisError.Code, message?: string) +} + +/** Cordis error code definitions. */ +namespace CordisError { + export type Code = keyof typeof Code + + export const Code = { + INACTIVE_EFFECT: 'cannot create effect on inactive context', + } as const +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L156) + +## ValidationError + +Error raised when plugin configuration fails standard-schema validation. + +```ts cordis-catalog +/** Error raised when plugin configuration fails standard-schema validation. */ +class ValidationError extends TypeError { + name = 'ValidationError' + + /** + * Build the aggregated message from schema issues. + * + * @param issues — the standard-schema issues, one message line each. + */ + constructor(issues: readonly StandardSchemaV1.Issue[]) +} +``` + +[Source](../../../vendor/cordis/src/fiber.ts#L18) diff --git a/docs/cordis-catalog/core/registry.md b/docs/cordis-catalog/core/registry.md new file mode 100644 index 0000000000..2772dca723 --- /dev/null +++ b/docs/cordis-catalog/core/registry.md @@ -0,0 +1,152 @@ + + +# Registry + +Plugin loading and dependency injection. + +### ctx.inject(deps, callback) + +```ts cordis-catalog +/** + * Run a callback once the requested services are available. + * + * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback + * is unloaded and re-run whenever a required service changes. + * + * @param deps — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ +inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike +``` + +Run a callback once the requested services are available. + +Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloaded and re-run whenever a required service changes. + +- `deps` — required services, as an array or a name → config map. +- `callback` — plugin body called with `(ctx, config)`. + +**Returns** the fiber; awaiting it settles once loading finished. + +[Source](../../../vendor/cordis/src/registry.ts#L175) + +### ctx.plugin(plugin, ...args) + +```ts cordis-catalog +/** + * Load a plugin in the current context. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param args — the plugin config, validated against its `Config` schema. + * @returns the fiber; awaiting it settles once loading finished + * (rejecting on config or startup errors). + */ +plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike +``` + +Load a plugin in the current context. + +- `plugin` — a function, class, or `{ apply }` object plugin. +- `args` — the plugin config, validated against its `Config` schema. + +**Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). + +[Source](../../../vendor/cordis/src/registry.ts#L184) + +## Plugin + +Supported plugin entrypoint shapes. + +```ts cordis-catalog +/** Supported plugin entrypoint shapes. */ +type Plugin = + | Plugin.Function + | Plugin.Constructor + | Plugin.Object + +/** Types associated with plugin entrypoints and runtime records. */ +namespace Plugin { + /** Shared metadata understood by the plugin registry and related tooling. */ + export interface Base { + /** Display name used for fiber diagnostics and logger names. */ + name?: string + /** Standard-schema validator applied to config before the plugin starts. */ + Config?: StandardSchemaV1 + /** Services the plugin requires; it only loads while all are available. */ + inject?: Inject + /** Service name(s) the plugin provides (read by `Service` and by loaders). */ + provide?: string | string[] + /** Service names whose intercept config the plugin declares it consumes. */ + intercept?: Dict + } + + export interface Transform { + /** Marks the transform object as a schema/config transform. */ + schema?: true + /** Convert user-facing config to runtime config. */ + Config: (config: S) => T + } + + /** Function plugin called with `(ctx, config)`. */ + export interface Function extends Base { + (ctx: Context, config: T): any + } + + /** Class plugin constructed with `(ctx, config)`. */ + export interface Constructor extends Base { + new (ctx: Context, config: T): any + } + + /** Object plugin with an `apply(ctx, config)` method. */ + export interface Object extends Base { + apply(ctx: Context, config: T): any + } + + /** Mutable registry record shared by all fibers of one plugin callback. */ + export interface Runtime { + /** Display name copied from the first registered plugin shape. */ + name?: string + /** Every live fiber of this plugin (one per `ctx.plugin()` call). */ + fibers: DisposableList + /** The executable entrypoint all fibers share (registry identity key). */ + callback: globalThis.Function + /** Standard-schema validator applied to each fiber's config. */ + Config?: StandardSchemaV1 + } +} +``` + +[Source](../../../vendor/cordis/src/registry.ts#L91) + +## Inject + +Service dependency declaration accepted by plugins and the `@Inject` decorator. + +Array form requests services without intercept config. Object form maps each service name to optional intercept config for the plugin context. + +```ts cordis-catalog +/** + * Service dependency declaration accepted by plugins and the `@Inject` + * decorator. + * + * Array form requests services without intercept config. Object form maps each + * service name to optional intercept config for the plugin context. + */ +type Inject = (keyof M)[] | { [K in keyof M]?: M[K] } + +/** Utilities for normalizing plugin dependency declarations. */ +namespace Inject { + /** + * Convert array/object/class-inherited inject metadata into a plain map. + * + * @param inject — the declaration to normalize; `null`/`undefined` add nothing. + * @param result — the map to fill (service name → intercept config or `null`). + * @returns `result`. + */ + export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) +} +``` + +[Source](../../../vendor/cordis/src/registry.ts#L18) diff --git a/docs/cordis-catalog/core/service.md b/docs/cordis-catalog/core/service.md new file mode 100644 index 0000000000..84b74f98df --- /dev/null +++ b/docs/cordis-catalog/core/service.md @@ -0,0 +1,102 @@ + + +# Service + +The base class for context services. A subclass loaded as a plugin registers itself as `ctx.`. + +Base class for services that expose a named API on `ctx`. + +Subclasses call `super(ctx, name)` from their constructor. The service is registered immediately and is automatically removed with the owning fiber. + +[Source](../../../vendor/cordis/src/service.ts#L11) + +### service.name + +```ts cordis-catalog +/** The service name this instance is registered under. */ +public name!: string +``` + +The service name this instance is registered under. + +[Source](../../../vendor/cordis/src/service.ts#L30) + +## Static members + +### Service.init + +```ts cordis-catalog +/** Symbol key of an instance method run after construction (class plugins). */ +static readonly init: unique symbol +``` + +Symbol key of an instance method run after construction (class plugins). + +[Source](../../../vendor/cordis/src/service.ts#L13) + +### Service.check + +```ts cordis-catalog +/** Symbol key of the availability predicate passed to `ctx.provide()`. */ +static readonly check: unique symbol +``` + +Symbol key of the availability predicate passed to `ctx.provide()`. + +[Source](../../../vendor/cordis/src/service.ts#L15) + +### Service.config + +```ts cordis-catalog +/** Symbol key of the phantom intercept-config type parameter. */ +static readonly config: unique symbol +``` + +Symbol key of the phantom intercept-config type parameter. + +[Source](../../../vendor/cordis/src/service.ts#L17) + +### Service.invoke + +```ts cordis-catalog +/** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */ +static readonly invoke: unique symbol +``` + +Symbol key of the call body making a service callable (e.g. `ctx.logger()`). + +[Source](../../../vendor/cordis/src/service.ts#L19) + +### Service.extend + +```ts cordis-catalog +/** Symbol key of the helper deriving an extended service instance. */ +static readonly extend: unique symbol +``` + +Symbol key of the helper deriving an extended service instance. + +[Source](../../../vendor/cordis/src/service.ts#L21) + +### Service.tracker + +```ts cordis-catalog +/** Symbol key of the tracker metadata used for context tracing. */ +static readonly tracker: unique symbol +``` + +Symbol key of the tracker metadata used for context tracing. + +[Source](../../../vendor/cordis/src/service.ts#L23) + +### Service.resolveConfig + +```ts cordis-catalog +/** Symbol key of the intercept-config resolution helper below. */ +static readonly resolveConfig: unique symbol +``` + +Symbol key of the intercept-config resolution helper below. + +[Source](../../../vendor/cordis/src/service.ts#L25) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..e4a1314244 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -7,7 +7,7 @@ Every cordis event a plugin can listen to: exact signature, dispatch mode, and o This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. -The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md). Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..26d6130dc4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -7,7 +7,7 @@ Every `ctx.` service a plugin can call: the exact public interface with ori This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. -The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. +The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md). ## `ctx.agentLoop` — `AgentLoop` diff --git a/scripts/cordis-core-api.spec.ts b/scripts/cordis-core-api.spec.ts new file mode 100644 index 0000000000..d35899553c --- /dev/null +++ b/scripts/cordis-core-api.spec.ts @@ -0,0 +1,49 @@ +/** Tests for the generated Cordis core API reference. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + CORDIS_CORE_API_PAGES, + renderCordisCoreApiPage, + renderCordisCoreApiPages, + type CordisCoreApiPage, +} from './cordis-core-api.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('Cordis core API generation', () => { + it('renders the five detailed pages from pinned vendor declarations', () => { + const pages = renderCordisCoreApiPages() + expect([...pages.keys()]).toEqual(CORDIS_CORE_API_PAGES.map(page => page.out)) + expect(pages.get('docs/cordis-catalog/core/context.md')).toContain('### ctx.extend(meta?)') + expect(pages.get('docs/cordis-catalog/core/events.md')).toContain('## DispatchMode') + expect(pages.get('docs/cordis-catalog/core/fiber.md')).toContain('## EffectMeta') + expect(pages.get('docs/cordis-catalog/core/registry.md')).toContain('## Plugin') + expect(pages.get('docs/cordis-catalog/core/service.md')).toContain('### Service.resolveConfig') + + const fiber = pages.get('docs/cordis-catalog/core/fiber.md') ?? '' + expect(fiber).toContain('```\n\nRegister a cleanup-aware effect on this fiber.') + expect(fiber).toContain('- `execute` — the effect body; see `Effect` for accepted shapes.') + expect(fiber).toContain('**Returns** a disposer that tears the effect down and settles once done.') + }) + + it('rejects a public core class without source JSDoc', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-cordis-core-api-')) + roots.push(root) + mkdirSync(join(root, 'vendor/cordis/src'), { recursive: true }) + writeFileSync(join(root, 'vendor/cordis/src/service.ts'), 'export class Service {\n run(): string { return "ok" }\n}\n') + const page: CordisCoreApiPage = { + out: 'docs/cordis-catalog/core/service.md', + title: 'Service', + intro: 'Service API.', + sections: [{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }], + } + expect(() => renderCordisCoreApiPage(page, root)).toThrow('class Service') + }) +}) diff --git a/scripts/cordis-core-api.ts b/scripts/cordis-core-api.ts new file mode 100644 index 0000000000..a2400fdb54 --- /dev/null +++ b/scripts/cordis-core-api.ts @@ -0,0 +1,433 @@ +/** Generate detailed Cordis core API pages from pinned vendor declarations. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' +import { cordisModuleBody } from './cordis-walk.ts' + +const root = resolve(import.meta.dirname, '..') +const FENCE = 'ts cordis-catalog' + +/** One declaration group rendered on a Cordis core API page. */ +type CordisCoreApiSection = + | { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string } + | { kind: 'context-merge'; file: string; heading?: string } + | { kind: 'decl'; file: string; symbol: string } + +/** One generated Cordis core API page. */ +export interface CordisCoreApiPage { + out: string + title: string + intro: string + sections: CordisCoreApiSection[] +} + +/** Explicit editorial grouping for the pinned Cordis core surface. */ +export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [ + { + out: 'docs/cordis-catalog/core/context.md', + title: 'Context', + intro: 'The context is the core Cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods are documented on [Events](events.md), effects and the current fiber on [Fiber](fiber.md), and plugin loading on [Registry](registry.md).', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' }, + { kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' }, + ], + }, + { + out: 'docs/cordis-catalog/core/events.md', + title: 'Events', + intro: 'The event-dispatch API mixed into every context. Harness event declarations and their dispatch modes are generated separately in the [Cordis events catalog](../events.md).', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/events.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' }, + { kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' }, + ], + }, + { + out: 'docs/cordis-catalog/core/fiber.md', + title: 'Fiber', + intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber, and `ctx.effect()` delegates to it.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' }, + { kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' }, + { kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' }, + ], + }, + { + out: 'docs/cordis-catalog/core/registry.md', + title: 'Registry', + intro: 'Plugin loading and dependency injection.', + sections: [ + { kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' }, + { kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' }, + ], + }, + { + out: 'docs/cordis-catalog/core/service.md', + title: 'Service', + intro: 'The base class for context services. A subclass loaded as a plugin registers itself as `ctx.`.', + sections: [ + { kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' }, + ], + }, +] + +interface MemberDoc { + name: string + heading: string + signatures: string[] + jsDoc: string + doc: string + params: { name: string; text: string }[] + returns: string | null + source: string +} + +interface RenderContext { + scanRoot: string + cache: Map + violations: string[] +} + +function load(ctx: RenderContext, rel: string): { sf: ts.SourceFile; text: string } { + const cached = ctx.cache.get(rel) + if (cached !== undefined) return cached + const text = readFileSync(resolve(ctx.scanRoot, rel), 'utf8') + const entry = { sf: ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true), text } + ctx.cache.set(rel, entry) + return entry +} + +function sourceJsDoc(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + if (raw === '') return '' + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, node.getStart(sf)) + return raw.split('\n') + .map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent) + ? sourceLine.slice(indent.length) + : sourceLine) + .join('\n') +} + +function signatureOf(member: ts.Node, sf: ts.SourceFile): string { + const full = member.getText(sf) + const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body + ?? (member as { initializer?: ts.Node }).initializer + const signature = tail + ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') + : full + return signature.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() +} + +function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string { + const names = parameters + .filter(parameter => !(ts.isIdentifier(parameter.name) && parameter.name.text === 'this')) + .map((parameter) => { + const rest = parameter.dotDotDotToken ? '...' : '' + const optional = parameter.questionToken || parameter.initializer ? '?' : '' + return `${rest}${parameter.name.getText(sf)}${optional}` + }) + return `(${names.join(', ')})` +} + +function isPublicInstance(member: ts.ClassElement): boolean { + const modifiers = ts.getCombinedModifierFlags(member) + if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false + if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +function isPublicStatic(member: ts.ClassElement): boolean { + const modifiers = ts.getCombinedModifierFlags(member) + if (modifiers & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false + if (!(modifiers & ts.ModifierFlags.Static)) return false + if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false + return !member.name.getText().startsWith('_') +} + +type Member = ts.MethodDeclaration + | ts.MethodSignature + | ts.PropertyDeclaration + | ts.PropertySignature + | ts.GetAccessorDeclaration + +function memberDoc(ctx: RenderContext, where: string, name: string, group: Member[], rel: string): MemberDoc { + const { sf, text } = load(ctx, rel) + const first = group[0] + if (first === undefined) throw new Error(`cordis-core-api: empty member group for ${name}.`) + const rawDocs = group.map(member => sourceJsDoc(text, sf, member)) + const docIndex = rawDocs.findIndex(raw => parseJsDoc(raw).doc !== '') + const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '') + const doc = parseJsDoc(raw).doc + if (doc === '') ctx.violations.push(`${where} has no JSDoc prose.`) + const { params: tags, returns } = parseTags(raw) + const functionMembers = group.filter((member): member is ts.MethodDeclaration | ts.MethodSignature => + ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) + const docCarrier = functionMembers[docIndex === -1 ? 0 : docIndex] + const params: { name: string; text: string }[] = [] + if (docCarrier !== undefined) { + checkParams(where, 'cordis-core-api', docCarrier.parameters, tags, sf, + parameter => ts.isIdentifier(parameter.name) && parameter.name.text === 'this', ctx.violations) + if (docCarrier.type !== undefined) { + checkReturns(where, docCarrier.type, returns, sf, ctx.violations) + } else if (returns === null && ts.isMethodDeclaration(docCarrier)) { + ctx.violations.push(`${where} has no return type annotation; document the result with @returns.`) + } + for (const parameter of docCarrier.parameters) { + if (!ts.isIdentifier(parameter.name) || parameter.name.text === 'this') continue + const text = tags.get(parameter.name.text) + if (text !== undefined) params.push({ name: parameter.name.text, text }) + } + } + const headingSource = docCarrier ?? functionMembers[0] + const signatures = ts.isMethodDeclaration(first) && functionMembers.length > 1 + ? functionMembers.filter(member => ts.isMethodDeclaration(member) && member.body === undefined) + : group + return { + name, + heading: headingSource === undefined ? '' : headingParams(headingSource.parameters, sf), + signatures: signatures.map(member => signatureOf(member, sf)), + jsDoc: raw, + doc, + params, + returns, + source: pointer(rel, sf, first), + } +} + +function heritageMembers( + statement: ts.InterfaceDeclaration, + sf: ts.SourceFile, + groups: Map, +): void { + for (const clause of statement.heritageClauses ?? []) { + for (const type of clause.types) { + if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue + const [target, keys] = type.typeArguments ?? [] + if (target === undefined || keys === undefined || !ts.isTypeReferenceNode(target)) continue + const targetName = target.typeName.getText(sf) + const cls = sf.statements.find( + (entry): entry is ts.ClassDeclaration => ts.isClassDeclaration(entry) && entry.name?.text === targetName, + ) + if (cls === undefined) continue + const picked = new Set() + const collect = (node: ts.TypeNode): void => { + if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text) + if (ts.isUnionTypeNode(node)) node.types.forEach(collect) + } + collect(keys) + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue + const name = member.name.getText(sf) + if (!picked.has(name)) continue + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + } + } +} + +function contextMergeMembers(ctx: RenderContext, rel: string): MemberDoc[] { + const { sf } = load(ctx, rel) + const body = cordisModuleBody(sf) + if (body === null) throw new Error(`cordis-core-api: ${rel} has no Context module merge.`) + const groups = new Map() + for (const statement of body.statements) { + if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'Context') continue + heritageMembers(statement, sf, groups) + for (const member of statement.members) { + if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue + if (ts.isComputedPropertyName(member.name)) continue + const name = member.name.getText(sf) + const group = groups.get(name) ?? [] + group.push(member) + groups.set(name, group) + } + } + return [...groups.entries()].map(([name, group]) => + memberDoc(ctx, `ctx.${name} (${rel})`, name, group, rel)) +} + +function classMembers(ctx: RenderContext, rel: string, className: string): { + doc: string + instance: MemberDoc[] + statics: MemberDoc[] + source: string +} { + const { sf, text } = load(ctx, rel) + const cls = sf.statements.find( + (statement): statement is ts.ClassDeclaration => + ts.isClassDeclaration(statement) && statement.name?.text === className, + ) + if (cls === undefined) throw new Error(`cordis-core-api: class ${className} not found in ${rel}.`) + const doc = parseJsDoc(rawJsDoc(text, cls)).doc + if (doc === '') ctx.violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`) + const instance = new Map() + const statics = new Map() + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member) && !ts.isPropertyDeclaration(member) && !ts.isGetAccessorDeclaration(member)) continue + const name = member.name.getText(sf) + if (isPublicInstance(member)) { + const group = instance.get(name) ?? [] + group.push(member) + instance.set(name, group) + } else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) { + const group = statics.get(name) ?? [] + group.push(member) + statics.set(name, group) + } + } + const declaration = sf.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && statement.name.text === className, + ) + for (const member of declaration?.members ?? []) { + if (!ts.isPropertySignature(member) || ts.isComputedPropertyName(member.name)) continue + const name = member.name.getText(sf) + const group = instance.get(name) ?? [] + group.push(member) + instance.set(name, group) + } + const render = (groups: Map, prefix: string): MemberDoc[] => + [...groups.entries()].map(([name, group]) => memberDoc(ctx, `${prefix}${name} (${rel})`, name, group, rel)) + return { + doc, + instance: render(instance, `${className}#`), + statics: render(statics, `${className}.`), + source: pointer(rel, sf, cls), + } +} + +function stripBodies(node: ts.Node, sf: ts.SourceFile): string { + const cuts: { start: number; end: number }[] = [] + const visit = (entry: ts.Node): void => { + const functionLike = ts.isMethodDeclaration(entry) + || ts.isConstructorDeclaration(entry) + || ts.isFunctionDeclaration(entry) + || ts.isGetAccessorDeclaration(entry) + || ts.isSetAccessorDeclaration(entry) + if (functionLike && entry.body !== undefined) { + const signatureEnd = (entry.type ?? entry.parameters.at(-1) ?? entry).getEnd() + cuts.push({ start: signatureEnd, end: entry.body.getEnd() }) + return + } + entry.forEachChild(visit) + } + visit(node) + const base = node.getStart(sf) + let output = node.getText(sf) + for (const cut of cuts.sort((left, right) => right.start - left.start)) { + const head = output.slice(0, cut.start - base) + const between = output.slice(cut.start - base, cut.end - base) + const bodyBrace = between.indexOf('{') + output = head + between.slice(0, bodyBrace).trimEnd() + output.slice(cut.end - base) + } + return output +} + +function declarationPaste(ctx: RenderContext, rel: string, symbol: string): { doc: string; code: string; source: string } { + const { sf, text } = load(ctx, rel) + const matches = sf.statements.filter((statement) => { + const named = ts.isInterfaceDeclaration(statement) + || ts.isTypeAliasDeclaration(statement) + || ts.isClassDeclaration(statement) + || ts.isEnumDeclaration(statement) + || ts.isModuleDeclaration(statement) + return named && statement.name?.getText(sf) === symbol + }) + const first = matches[0] + if (first === undefined) throw new Error(`cordis-core-api: declaration ${symbol} not found in ${rel}.`) + const doc = parseJsDoc(sourceJsDoc(text, sf, first)).doc + const code = matches.map((statement) => { + const jsDoc = sourceJsDoc(text, sf, statement) + const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '') + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + }).join('\n\n') + return { doc, code, source: pointer(rel, sf, first) } +} + +function sourceLink(source: string): string { + const [file, line] = source.split(':') + return `[Source](../../../${file}${line === undefined ? '' : `#L${line}`})` +} + +function unlink(text: string): string { + return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_match, target: string, label?: string) => { + const name = label?.trim() + return name && name !== '' ? name : `\`${target}\`` + }) +} + +function prose(doc: string): string[] { + const paragraphs = unlink(doc) + .split(/\n\s*\n/) + .map(paragraph => paragraph.replace(/\s*\n\s*/g, ' ').trim()) + .filter(paragraph => paragraph !== '') + return paragraphs.flatMap((paragraph, index) => index === 0 ? [paragraph] : ['', paragraph]) +} + +function renderMember(prefix: string, member: MemberDoc): string[] { + const lines = [`### ${prefix}${member.name}${member.heading}`, '', `\`\`\`${FENCE}`] + if (member.jsDoc !== '') lines.push(member.jsDoc) + lines.push(...member.signatures, '```', '') + if (member.doc !== '') lines.push(...prose(member.doc), '') + for (const parameter of member.params) lines.push(`- \`${parameter.name}\` — ${unlink(parameter.text)}`) + if (member.params.length > 0) lines.push('') + if (member.returns !== null && member.returns !== '') lines.push(`**Returns** ${unlink(member.returns)}`, '') + lines.push(sourceLink(member.source), '') + return lines +} + +/** Render one detailed Cordis core API page and reject undocumented members. */ +export function renderCordisCoreApiPage( + page: CordisCoreApiPage, + scanRoot: string = root, +): string { + const ctx: RenderContext = { scanRoot, cache: new Map(), violations: [] } + const lines = [ + '', + '', + `# ${page.title}`, + '', + page.intro, + '', + ] + for (const section of page.sections) { + if (section.kind !== 'decl' && section.heading !== undefined) lines.push(`## ${section.heading}`, '') + if (section.kind === 'context-merge') { + for (const member of contextMergeMembers(ctx, section.file)) lines.push(...renderMember('ctx.', member)) + } else if (section.kind === 'class') { + const cls = classMembers(ctx, section.file, section.symbol) + if (cls.doc !== '') lines.push(...prose(cls.doc), '') + lines.push(sourceLink(cls.source), '') + const prefix = section.prefix ?? `${section.symbol.toLowerCase()}.` + for (const member of cls.instance) lines.push(...renderMember(prefix, member)) + if (cls.statics.length > 0) { + lines.push('## Static members', '') + for (const member of cls.statics) lines.push(...renderMember(`${section.symbol}.`, member)) + } + } else { + const declaration = declarationPaste(ctx, section.file, section.symbol) + lines.push(`## ${section.symbol}`, '') + if (declaration.doc !== '') lines.push(...prose(declaration.doc), '') + lines.push(`\`\`\`${FENCE}`, declaration.code, '```', '', sourceLink(declaration.source), '') + } + } + reportViolations('gen-cordis-catalog', ctx.violations) + return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n` +} + +/** Render every detailed Cordis core API page. */ +export function renderCordisCoreApiPages(scanRoot: string = root): Map { + return new Map(CORDIS_CORE_API_PAGES.map(page => [page.out, renderCordisCoreApiPage(page, scanRoot)])) +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 9ed088aa1d..fd6fa051f5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -5,9 +5,10 @@ * curated table below. `--check` verifies both committed artifacts. */ -import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve, sep } from 'node:path' +import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, resolve, sep } from 'node:path' import ts from 'typescript' +import { renderCordisCoreApiPages } from './cordis-core-api.ts' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts' @@ -503,7 +504,7 @@ export function renderEvents(events: EventEntry[]): string { '', GATE_NOTICE, '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely.', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md).', '', 'Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`).', '', @@ -538,7 +539,7 @@ export function renderServices(services: ServiceEntry[]): string { '', GATE_NOTICE, '', - 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely.', + 'The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. Detailed Context, Fiber, Registry, and Service APIs are generated in the [Cordis core API](core/context.md).', '', ] for (const s of services) lines.push(...renderService(s)) @@ -562,6 +563,7 @@ function main(): void { const outputs: [string, string][] = [ [OUT_EVENTS, renderEvents(collectEvents())], [OUT_SERVICES, renderServices(collectServices())], + ...renderCordisCoreApiPages(), ] if (process.argv.includes('--check')) { const stale: string[] = [] @@ -578,15 +580,19 @@ function main(): void { if (committed !== content) stale.push(out) } if (stale.length === 0) { - console.log(`gen-cordis-catalog: ${OUT_EVENTS} and ${OUT_SERVICES} are up to date.`) + console.log(`gen-cordis-catalog: ${outputs.length} generated file(s) are up to date.`) process.exit(0) } console.error(`gen-cordis-catalog: ${stale.join(' and ')} ${stale.length === 1 ? 'is' : 'are'} stale. Run \`pnpm run gen-cordis-catalog\` and commit the result.`) process.exit(1) } - for (const [out, content] of outputs) writeFileSync(resolve(root, out), content) - console.log(`gen-cordis-catalog: wrote ${OUT_EVENTS} and ${OUT_SERVICES}.`) + for (const [out, content] of outputs) { + const destination = resolve(root, out) + mkdirSync(dirname(destination), { recursive: true }) + writeFileSync(destination, content) + } + console.log(`gen-cordis-catalog: wrote ${outputs.length} generated file(s).`) } // Run only when invoked as a script, not when imported by a test. diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 7378152ac5..bd6cfb14c7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -160,6 +160,18 @@ describe('docsPages locale routes', () => { } } }) + + it('publishes the Cordis core API under matching locale structures', () => { + const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md'] + for (const file of files) { + const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`) + const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`) + expect(root?.source).toBe(`docs/cordis-catalog/core/${file}`) + expect(root?.section).toBe('Cordis API') + expect(english?.source).toBe(root?.source) + expect(english?.section).toBe('Cordis Core API') + } + }) }) describe('addProjectionFrontmatter', () => { diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b553d6b2da..b9f38b0f7e 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -15,6 +15,7 @@ const sectionOrder = [ '实战', '概念', '生成参考', + 'Cordis API', '数据结构', '开发手册', 'Guide', @@ -23,6 +24,7 @@ const sectionOrder = [ 'Practice', 'Concepts', 'Generated reference', + 'Cordis Core API', 'Data structures', 'Cookbook', ] diff --git a/website/docs.ts b/website/docs.ts index 0e6897afe3..528fa36110 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -237,6 +237,21 @@ const reference = mirroredPages([ section: { root: '生成参考', en: 'Generated reference' }, order, })), + ...([ + ['context.md', 'Context', 'Context'], + ['events.md', 'Events', 'Events'], + ['fiber.md', 'Fiber', 'Fiber'], + ['registry.md', 'Plugin Registry', 'Plugin Registry'], + ['service.md', 'Service', 'Service'], + ] as const).map(([file, rootLabel, enLabel], order): MirroredPage => ({ + source: `docs/cordis-catalog/core/${file}`, + route: `reference/cordis-api/${file}`, + contentLocale: 'en-US', + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: 'Cordis API', en: 'Cordis Core API' }, + order, + })), ...([ ['core.md', '核心数据结构', 'Core data structures'], ['scope.md', '作用域', 'Scopes'], From 6eaa6073a25cbc1b798a49b5e0be9b6d196d86e4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:32:17 +0800 Subject: [PATCH 28/28] test(code-runtime): stabilize slow-binding budget case --- .../code-runtime/code-runtime-worker/tests/runtime.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 23c9a8ee60..ae2cb2b639 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -129,10 +129,12 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { }, 15_000) it('does not charge time spent awaiting a slow binding against the compute budget', async () => { - const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 }) + // Keep the binding delay above the compute allowance while leaving enough + // headroom for worker bootstrap on loaded CI hosts. + const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 }) const result = await runtime.run({ program: 'return await tools.slow({})', - bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }), + bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }), }) expect(result.error).toBeUndefined() expect(result.value).toBe('slow-done')