diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 56c9e53319..b6788a47ef 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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 .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de -2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8 +2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e +2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index a8ffe91c43..ad9d65805c 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists +- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,7 +77,7 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics). +**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index ac4affce58..3021c7fcca 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。 +**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 48d3522a70..072823d0dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.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 .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md -2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b -2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23 +2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a +2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index d82141f519..67607b2848 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. +An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. ## Decision diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index e145908e01..9cbab75ce8 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -14,7 +14,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 +一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 8a2190b500..26354042d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.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 .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 94f5fa409e..a3b5021daf 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too. **The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 341b3a89f4..490d41302e 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -10,7 +10,7 @@ Status: implemented **模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。 -**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。 +**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。 **Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index d326b413bb..2926746ee1 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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 .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e -2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d +2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab +2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 2f559a3052..ebca9b99ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model- - Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. - Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. -- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 26abdc024a..91ef3fd045 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -53,4 +53,3 @@ Status: implemented - 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。 - 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 - 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 -- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml index 9300571e28..2969995da6 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.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 .agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md -2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41 -2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f +2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a +2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md index d75b6bdb91..f908eb6293 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.md @@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it: -- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. +- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already. - `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused. - `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`. - A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked. @@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with** pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected. -`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. +`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives. ### Credentials stay outside pi-ai @@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's - **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports. - **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations. - **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working. -- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported. +- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason. - **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it. - **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity. diff --git a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md index f8dba9900b..ce91abd6dc 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md @@ -14,7 +14,7 @@ Status: implemented 提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`: -- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 +- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。 - `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。 - `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。 - 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。 @@ -35,7 +35,7 @@ Status: implemented pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。 -因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 +因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。 ### 凭据留在 pi-ai 之外 @@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred - **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。 - **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。 - **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。 -- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。 +- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。 - **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。 - **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index fb5ee5debc..b1bdfe74c2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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 .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2 +2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 743535d039..74b672b3f9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -6,7 +6,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Problem -`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. +`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In the reproducing run, three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 1e51070f88..32c667dc09 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 +`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在复现运行中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml index e1c3ac3ef8..f4f105e124 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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 .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md -2026-08-06-api-key-format-validation.md: e9ca76ede06080f2b868f6436998d163e642adbc -2026-08-06-api-key-format-validation.zh.md: 5666a884d4c9478291072375681d8d3526b2632a +2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f +2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md index e9ca76ede0..2174cb466c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md @@ -14,13 +14,11 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take. -Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210. - ## Decision One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded. -This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes. +This single predicate covers every reported input: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the failures share one definition rather than two coincidentally related fixes. A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do. @@ -76,7 +74,7 @@ The client cannot import any of this: client packages reference only client pack **Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open. -**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. +**Probing the provider at save time to prove the key works.** It would close the original complaint — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md index 5666a884d4..f9b7d6518f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md @@ -14,13 +14,11 @@ Status: implemented 空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。 -来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。 - ## Decision 一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。 -这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。 +这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。 第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。 @@ -76,7 +74,7 @@ Status: implemented **让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。 -**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 +**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index c981d84400..9e195a4b1d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.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 .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 26e6e2468c7bea661d85c8fb994adf8b109105ee -2026-06-18-compaction-capability-seam.zh.md: 8f9cd1f6bc31e648a5b923e816cad75ebc1a0bd8 +2026-06-18-compaction-capability-seam.md: 8dcbe74429a620027a570124383442b969c12196 +2026-06-18-compaction-capability-seam.zh.md: 27b63f29c2e6f35637185b47c882ae42e5d41088 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 26e6e2468c..8dcbe74429 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. +- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response; keyless assembled coverage therefore needs an explicit replay override. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 8f9cd1f6bc..27b63f29c2 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。 +- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应;因此,要实现无密钥的组装态覆盖,就必须显式提供回放 override。 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml index 6b0ce3f378..8a92dd2fd1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.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 .agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md -2026-07-26-todo-parallel-in-progress.md: 2805ef894050d1b1cffe06fce59a4984d463f8d1 -2026-07-26-todo-parallel-in-progress.zh.md: 16b32daa05b10f24eacde4cec9622b2159cdef09 +2026-07-26-todo-parallel-in-progress.md: 8d047f33ab1aebd8c0de2a0a5e90e7efb1b28154 +2026-07-26-todo-parallel-in-progress.zh.md: 26b7081e7b4e3688a46a2857da91ba45328532a9 diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md index 2805ef8940..8d047f33ab 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.md @@ -37,7 +37,7 @@ The durable-log invariant deliberately does NOT follow the flag. A log written w ## The display surfaces are part of the change -Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign in [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) has since replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. +Lifting the cap makes a list shape reachable that no renderer had ever received, so this branch stacks on the [web todo display](2026-07-23-web-todo-display.md) rather than landing beside it: both change `tool-todo`, and the GUI is where a parallel plan becomes visible. Two web sites derived their one-line summary with `todos.find(t => t.status === 'in_progress')` — the collapsed plan-strip header and the `todo_write` row — and under the old cap that `find` was total, since at most one item could match. With several active it silently dropped every active item but the first: a four-item plan with three running tasks collapsed to the name of one, and the row read `1/4 已完成 · ` while two others were in flight. The expanded list was always correct (it maps every item), which is why neither PR's tests caught it — only the collapsed header and the row lost information. The panel redesign replaced the collapsed header's named hint with `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted), which reports parallel work correctly and needs no name to truncate; the row is the one site this branch still had to fix. The row takes `planSummary` in `toolviews/plan-summary.ts`. It names the first active item and counts the rest, so the row reports how many tasks are running instead of implying one. Naming every active item was rejected: the row is a single line, and an unbounded join would overflow it — the count degrades predictably where a list does not. The derivation sits inside the toolviews domain rather than in `contract/`, the inter-domain face: the panel computes its own counts inline and shares nothing with the row, so a contract module would declare a sharing relationship that no longer exists. diff --git a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md index 16b32daa05..26b7081e7b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-todo-parallel-in-progress.zh.md @@ -37,7 +37,7 @@ Status: implemented ## 展示面是本次改动的一部分 -解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。其后 [#740](https://github.com/deepseek-harness/deepseek-harness/pull/740) 的面板重做已把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 +解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。 工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml index f06403d181..1ceb5a5213 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.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 .agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md -2026-07-30-queued-manual-compaction.md: 4b7a905712a01948146b8830dfc037185162eefc -2026-07-30-queued-manual-compaction.zh.md: 15a42de3536f2da5304e77ce3cb282029856ba6d +2026-07-30-queued-manual-compaction.md: 5100808ada4b7b284228113584e577d46ff91101 +2026-07-30-queued-manual-compaction.zh.md: 29c3b921d0d59527aa1d8af1c91d085b52045377 diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md index 4b7a905712..5100808ada 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.md @@ -75,7 +75,7 @@ Once a transaction has appended its start, every later failure makes one closing ### Reference implementation boundaries -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) was used as a reference implementation for the command, reservation, tests, and snapshot shape, but was not merged. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. +An unmerged reference implementation informed the command, reservation, tests, and snapshot shape. Its process-local `WeakSet` lock and locked/unlocked method splits were considered and not adopted because the durable bracket is the single reachable lock. That reference also carried client-side replacement-anchor machinery to preserve transcript placement. The log-ordered transcript projection already consumes compaction from event order and does not consult mutable surface positions, so those anchors were considered and not adopted. diff --git a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md index 15a42de353..29c3b921d0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-queued-manual-compaction.zh.md @@ -75,7 +75,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试 ### 参考实现边界 -[PR #835](https://github.com/deepseek-harness/deepseek-harness/pull/835) 用作命令、预留、测试与快照结构的参考实现,但未被合并。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 +一个未合并的参考实现为命令、预留、测试与快照结构提供了参考。它的进程本地 `WeakSet` 锁与 locked/unlocked 方法拆分经过评估后未被采用,因为持久标记对是唯一可达的锁。 该参考实现还包含客户端侧替换锚点机制,用于保留 transcript(文本记录)位置。按日志顺序排列的 transcript 投影已经从事件顺序消费压缩,并且不会查询可变 surface 位置,因此这些锚点经过评估后未被采用。 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index eea7ced3d2..40e24e9ac2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee -2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 +2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade +2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index 945d2d5243..e35a903892 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -14,7 +14,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts. - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. -- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. +- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. - **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index f537e313a0..d7a68f0cab 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -14,7 +14,7 @@ Status: implemented - **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。 - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 -- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 +- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 - **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml index dcb3a9406b..51401adfdc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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 .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md -2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 -2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 +2026-08-05-pwsh-ui-bash-parity.md: 815b448b894e9c53b4c4a2076f6b94fdd316dd35 +2026-08-05-pwsh-ui-bash-parity.zh.md: 967c5a9e1409028043dc5028fdca640ddfeb1acc diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md index 6bbdb0e6bc..815b448b89 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) ## Problem -The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md index 75f3a3ddec..967c5a9e14 100644 --- a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2,但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml new file mode 100644 index 0000000000..3639c8da6b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md new file mode 100644 index 0000000000..b6264feeb7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -0,0 +1,33 @@ +# Agent Note: Per-Model Reasoning Declarations in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md) + +## Problem + +Under the declared-provider catalog ([[2026-08-03-pi-ai-declared-provider-catalog]], which deliberately kept reasoning out of the configurable fields), a hand-declared pi-ai route's models materialized with `reasoning: false`, so `getSupportedThinkingLevels` short-circuited to `["off"]`: the composer offered no effort picker for them, and the route-level `reasoning` default — the only reasoning knob a profile had — made every request to such a model fail with `UNSUPPORTED_REASONING_EFFORT` before network I/O. The same route-level knob was also the wrong altitude for catalog routes: one provider's models disagree about which levels they accept (deepseek ships `[off, high, max]` beside catalog models with `xhigh`), so a single per-route level could not be set without breaking part of the route, which is why the Models page stopped writing it entirely (#1860) and left `settings.yaml` with no way to align efforts per model. + +Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (`compat.thinkingFormat`, `compat.supportsReasoningEffort`) by recognizing the endpoint URL, and a private gateway's URL says nothing — a DeepSeek-dialect gateway was spoken to in the OpenAI dialect with no configuration that could correct it. And the only way to touch one catalog model was the `models` list, which *replaces* the served catalog: narrowing `gpt-5`'s levels meant restating all thirty-eight openai models or silently dropping thirty-seven. + +## Decision + +`PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. + +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). + +`modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). + +## Alternatives considered + +- **Pass `reasoning` + `thinkingLevelMap` through verbatim** (pi-ai's own radius-config shape). Rejected by the user for operator confusion: the map's `null`-marks-unsupported convention plus the asymmetric absent-key rule mean the config's meaning depends on knowledge of pi-ai internals; the chosen shape makes the key set itself the offer. +- **A bare level list** (`reasoningEfforts: [off, high]`). Cannot express wire renames, and the catalog's own maps prove renames are real: 66 of 1230 installed map entries are non-identity (`off→none`, `minimal→low`, `low→LOW`, `high→default`). +- **`{}` as the disable spelling.** Unimplementable: schemastery materializes an absent dict as `{}`, so every model without the field would have been force-disabled. +- **Folding this into the route-level `reasoning` knob.** That knob is a *default selection*, not a capability set; it stays, and a declared model's efforts now bound what it can select. + +## Consequences + +- The composer's effort pane works for hand-declared models with zero UI change — `resolveModelInfo` reports declared levels through the same seam catalog metadata uses (pinned by the `declared-reasoning` web scenario). +- #1860's deferred gap — a route-level effort a model cannot take failing its requests — now has an operator remedy: align the model's `reasoningEfforts` or drop the route default. +- There is deliberately no spelling for returning one map key or compat field to "whatever the catalog said": the declaration is the whole offer, so keeping a catalog value means restating it. The README documents this. +- `verify-package-invariants` is untouched: the feature adds configuration resolution, no new events or mutable runtime relations. diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md new file mode 100644 index 0000000000..1b30f7e0c4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -0,0 +1,33 @@ +# Agent Note: llm-pi-ai 的按模型推理声明 + +Status: implemented + +[English](2026-08-08-pi-ai-per-model-reasoning-declarations.md) | 中文 + +## 问题 + +在声明式提供方 catalog([[2026-08-03-pi-ai-declared-provider-catalog]],它刻意把推理排除在可配置字段之外)之下,手工声明的 pi-ai 路由,其模型物化出来就带着 `reasoning: false`,于是 `getSupportedThinkingLevels` 短路成 `["off"]`:输入框不为它们提供档位选择器,而路由级的 `reasoning` 默认值——当时 profile 仅有的推理旋钮——让发往这类模型的每个请求都在网络 I/O 之前以 `UNSUPPORTED_REASONING_EFFORT` 失败。同一个路由级旋钮对 catalog 路由来说也放错了层级:同一提供方下各模型接受的档位并不一致(deepseek 自带 `[off, high, max]`,旁边就是带 `xhigh` 的 catalog 模型),单个路由级档位怎么设都会弄坏路由的一部分——这正是模型页彻底停写它的原因(#1860),而 `settings.yaml` 也因此没有了任何按模型对齐档位的办法。 + +两个相邻的缺口让问题雪上加霜。pi-ai 靠识别端点 URL 来决定推理的*协议方言*(`compat.thinkingFormat`、`compat.supportsReasoningEffort`),而私有网关的 URL 什么也说明不了——说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且没有任何配置能更正它。另外,想动单个 catalog 模型,唯一的手段是 `models` 列表,而它会*替换*所服务的 catalog:收窄 `gpt-5` 的档位,意味着要么重述全部三十八个 openai 模型,要么静默丢掉三十七个。 + +## 决策 + +`PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 + +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 + +`modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 + +## 曾考虑的替代方案 + +- **把 `reasoning` + `thinkingLevelMap` 原样透传**(pi-ai 自家 radius 配置的形状)。用户以运维人员困惑为由否决:map 用 `null` 标记「不支持」的约定,加上不对称的键缺席规则,意味着这份配置的含义取决于对 pi-ai 内部机制的了解;选定的形状则让键集合本身就是对外提供的全部。 +- **裸档位列表**(`reasoningEfforts: [off, high]`)。表达不了协议侧改名,而 catalog 自己的 map 证明改名真实存在:1230 条已安装 map 条目里有 66 条不是恒等映射(`off→none`、`minimal→low`、`low→LOW`、`high→default`)。 +- **用 `{}` 作为禁用拼写。** 无法实现:schemastery 会把缺席的字典物化成 `{}`,于是每个没写该字段的模型都会被强制禁用。 +- **把这件事并进路由级的 `reasoning` 旋钮。** 那个旋钮是*默认选择*,不是能力集合;它保留下来,而已声明模型的档位如今约束着它能选什么。 + +## 后果 + +- 输入框的档位面板对手工声明的模型直接可用,UI 零改动——`resolveModelInfo` 经 catalog 元数据所走的同一 seam 报告已声明档位(由 `declared-reasoning` web 场景钉住)。 +- #1860 暂缓的缺口——模型接不住的路由级档位会让发往它的请求失败——如今有了运维侧补救:对齐该模型的 `reasoningEfforts`,或去掉路由默认值。 +- 刻意不提供任何把单个 map 键或 compat 字段交还给「catalog 原本怎么说」的拼写:这份声明就是对外提供的全部,要保留某个 catalog 值就得重述它。README 记载了这一点。 +- `verify-package-invariants` 原封未动:该功能新增的是配置解析,没有新事件,也没有可变的运行时关系。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml new file mode 100644 index 0000000000..3774ba6e69 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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 .agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +2026-08-08-user-explicit-skill-invocation.md: d925938279923282170dc99934f4fa44d8ecf2b4 +2026-08-08-user-explicit-skill-invocation.zh.md: 64e23be0b42519fb9681adefcd0f05074d3aa35e diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md new file mode 100644 index 0000000000..d925938279 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -0,0 +1,36 @@ +# Agent Note: User-explicit skill invocation at the pre-step gesture boundary + +Status: implemented + +English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) + +## Problem + +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the decision-21 plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. + +## Decision + +User-explicit invocation is a host-side pre-step injection, uniform for every user-invocable skill and every front end: + +- `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `workspace-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. +- Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. +- The client stays decision 21: a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. + +Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every front end from implementing recognition. + +## Alternatives considered + +- **`skill.invoke` RPC (host injects, client claims)** — implemented first, in two iterations: a single mixed message (user text folded into the body), then a gesture prompt plus injection delivered through inbox primitives. Rejected after real-session testing: the mixed message polluted the injection with user prose; the two-message form depended on wake-ordering subtleties (`followup` claims the whole next-turn queue synchronously inside the first waking call, stranding any later message in the next turn — reproduced live), and the dedicated RPC duplicated a path `session.prompt` already provides while leaving TUI/ACP to reimplement recognition. The pre-step seam removes the RPC, the claim machinery, and the ordering hazard outright. +- **`agent.inject()` from the RPC handler** — the inject queue (`next-step`, wake-free) is claimed ahead of the next-turn prompt, putting the injection above the gesture in the log; and pairing it with a waking `followup` reintroduces the same ordering coupling. The pre-step listener injects inside the step assembly, where ordering is explicit. +- **A host `/skill ` command** (command registry, plan-mode precedent) — two-token UX, no name completion, and user-only skills stay undiscoverable in the menu; the per-cwd skill catalog also fits the static command registry poorly. Rejected. +- **Client-side expansion** (fetch body, splice into the prompt) — authorization becomes bypassable client courtesy, the log loses the invocation semantics, and Codex deleted its equivalent mechanism (custom prompts) in favor of core injection. Rejected. +- **Structured reference payload on the prompt wire** (Codex's `UserInput::Skill` analogue: the client ships `{skills: [...]}` beside the text and the boundary prefers it over scanning) — considered and deferred: the existing slash-command system is itself line-text on the wire, and closed-set directory matching already removes the guesswork; recorded as a ledger item should gesture precision ever need client intent. +- **Per-injection preamble line** (Kimi's `User activated the skill …`) — dropped in favor of the one-time catalog sentence: same context, paid once, and the injected block stays byte-identical with the tool result. + +## Consequences + +- Decision 21's plain-text reference is now the whole client story: the draft carries plain text, chip visuals derive from the lexicon, and the sent text is judged by the host boundary — a hand-typed gesture, a menu pick, and a TUI prompt are indistinguishable and equally deterministic. +- Every user-invocable skill invocation costs its full rendered body unconditionally — the price of determinism the peer survey showed everyone pays. Mentioning a known skill name mid-sentence loads it; that is the Codex mention semantic, accepted deliberately. +- The `skill-invocation` source rides `user/message`, so Model-visible ⟺ logged holds with no new event type, and replay/UI read metadata rather than text markers. +- Accepted residual of dropping the per-injection preamble: the no-reload framing rides only the catalog, and a workspace whose skills are all user-only never publishes a first catalog — an injection can arrive with no framing at all, and the model may redundantly try the `skill` tool once (the replacement catalog's empty arm carries the sentence; the never-published case does not). Publishing a catalog for framing alone was judged worse than that one recoverable error. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md new file mode 100644 index 0000000000..64e23be0b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -0,0 +1,36 @@ +# Agent Note: pre-step 手势边界上的用户显式 skill 调用 + +Status: implemented + +[English](2026-08-08-user-explicit-skill-invocation.md) | 中文 + +## 问题 + +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),回车提交的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,决策 21 的纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 + +## 决策 + +用户显式调用是一次宿主侧的 pre-step 注入,对每一个用户可调用的 skill 和每一种前端一致: + +- `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `workspace-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall 会把携带目录的列表交给它来扩展。 +- 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 +- 客户端停留在决策 21:菜单 pick 落下字面文本 `/name `,提示词将其原样发出;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——裁决在客户端把该行认领走,它尚未成为提示词。 +- 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,与 `skill` 工具结果逐字共享,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 + +同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种前端免于自行实现识别。 + +## 考虑过的替代方案 + +- **`skill.invoke` RPC(宿主注入、客户端认领)**——最先实现,共两轮迭代:先是单条混合消息(用户文本折进正文),后是经 inbox 原语投递的手势提示词加注入两条消息。经真实会话测试后否决:混合消息让用户行文污染了注入;两条消息的形态依赖唤醒顺序的微妙之处(`followup` 在第一个唤醒调用内同步认领整个 next-turn 队列,把之后的消息滞留到下一轮次——已实际复现),而专设 RPC 复制了 `session.prompt` 已提供的路径,还让 TUI/ACP 不得不各自重新实现识别。pre-step seam 把 RPC、认领机制与顺序隐患一并干净移除。 +- **从 RPC 处理器调用 `agent.inject()`**——inject 队列(`next-step`,不唤醒)会在 next-turn 提示词之前被认领,使注入在日志中排到手势之上;而与会唤醒的 `followup` 搭配又会重新引入同样的顺序耦合。pre-step 监听器在步骤组装内部注入,那里的顺序是显式的。 +- **宿主 `/skill ` 命令**(命令注册表,plan 模式先例)——两 token 的 UX、没有名称补全、仅限用户的 skill 在菜单里仍不可发现;按 cwd 的 skill 目录也与静态命令注册表格格不入。否决。 +- **客户端展开**(拉取正文、拼进提示词)——授权沦为可被绕过的客户端善意,日志失去调用语义,而且 Codex 已删除其等价机制(custom prompts)转向核心注入。否决。 +- **提示词协议上的结构化引用载荷**(Codex `UserInput::Skill` 的类似物:客户端在文本旁附带 `{skills: [...]}`,边界优先采用它而不是扫描)——考虑过并暂缓:现有斜杠命令体系在协议上本身就是行文本,封闭集合的目录匹配已经消除了猜测;已记为台账事项,以备手势精确性某天需要客户端意图。 +- **每次注入一条前导语**(Kimi 的 `User activated the skill …`)——弃用,改为一次性的目录句子:同样的上下文、只支付一次,且注入块与工具结果保持逐字节一致。 + +## 后果 + +- 决策 21 的纯文本引用如今就是客户端的全部故事:草稿承载纯文本,chip 视觉由 lexicon 派生,发出的文本由宿主边界评判——手动键入的手势、菜单 pick 与 TUI 提示词无从区分,也同等确定。 +- 每一次用户可调用 skill 的调用都无条件付出其完整渲染正文的成本——这是确定性的代价,同类调研表明所有产品都在支付。在句子中间提到一个已知 skill 名称也会加载它;这就是 Codex 的 mention 语义,属于有意接受。 +- `skill-invocation` 来源搭乘 `user/message`,因此「模型可见 ⟺ 已记录」在不新增事件类型的情况下继续成立,回放与 UI 读取的是元数据而非文本标记。 +- 放弃逐次注入前导语后被接受的残余:no-reload framing 只搭乘目录,而 skill 全部为仅用户的工作区永远不会发布首个目录——注入可能在完全没有 framing 的情况下到达,模型可能多余地调用一次 `skill` 工具(替换目录的空臂携带该句;从未发布的情形没有)。仅为 framing 而发布目录被判定比这一次可恢复的错误更糟。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 7fa4d3fbba..07e0a89d0f 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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 .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0 -2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0 +2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 +2026-07-13-documentation-site-projection.zh.md: 5b6de4b3425b6d20335a0f8055468ced414d4034 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 f19d9b309a..d9af915754 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 @@ -18,7 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g 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 is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). 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. +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 source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). 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. + +`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index cc5e94e709..5b6de4b342 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,7 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 + +`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 8ccd5ca13e..1dc5d79676 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md -2026-07-22-evidence-based-larger-hosted-runners.md: d46b8291ec05e997728da76354354f9e36bd2fb4 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: f43712859d8351ffff45c7b6d5eb2b65015ee4c3 +2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 0484548c76cb7eed11dc4235ef024a700499ef6a diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index d46b8291ec..53cc86efce 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -26,7 +26,7 @@ The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. -An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: +An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: | Complete Linux primary | 4 cores | 8 cores | 16 cores | 32 cores | 64 cores | 96 cores | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ The same benchmark measured the required Windows build surfaces across every pro |---|---:|---:|---:|---:|---:|---:| | Active time | 152 s | 104 s | 104 s | 92 s | 103 s | 110 s | -Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A [retargeted production validation](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2) completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. +Repository work gains little above 16 Windows cores, but the 32-core pool can start the complete outer inventory together. A retargeted production validation completed the full one-box Windows inventory in 173 seconds, including coverage and snapshot replay, so Windows remains consolidated. -The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In [one exact-head candidate run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681), Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A [cacheless all-size trace](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155) completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. +The larger client package graph makes cache mechanics and scheduler pressure part of the measured workload. In one exact-head candidate run, Linux spent 39 seconds in repository gates but 69 seconds in the complete job, while Windows spent 117 seconds in repository gates and 228 seconds in the complete job. The Windows pnpm cache downloaded its 154 MB archive in about two seconds but spent 27 seconds extracting it, followed by a 23-second install and a 14-second post-job save. A cacheless all-size trace completed the same 32-core Windows install in 27 seconds. A future larger-runner rollout therefore needs complete-job measurements rather than gate-only timing. Host setup remains part of any comparison. A standard Node 26 job once spent 36 of its 67 seconds in `Set up job`, while `actions/setup-node` spent 46.56 seconds printing cached Windows environment details after finding Node in the hosted toolcache. A Linux candidate also spent 18 seconds registering a 50 KB Bubblewrap package because the hosted image scanned 202,507 package-database files. [`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) instead verifies and extracts the pinned payload into the ephemeral runner directory, runs a functional confinement probe, and overlaps that preparation with dependency installation. -Inner and outer worker limits are separate controls. An [exact-head 32-worker ESLint experiment](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463) slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. +Inner and outer worker limits are separate controls. An exact-head 32-worker ESLint experiment slowed lint to 52.28 seconds and coverage to 42.71 seconds, where an adapter idle-timeout test failed. A later 8-gate trace reduced coverage to 35.17 seconds but delayed the production-site build until the aggregate reached 41.06 seconds. Core count therefore does not justify copying an equally large worker limit. The process-bound coverage project contains exactly five suite files. Thirty-two forks crashed Node 24's CJS lexer twice, and a later 16-fork run reproduced the worker loss and invalid coverage result. The single Vitest invocation therefore uses threads for the broad inventory and reserves forks for suites that exercise process-global state, `process` APIs, or timing-sensitive process I/O. That narrow fork inventory includes the local bash process-plumbing suite and the pi-ai adapter suite because aggregate contention changed timing observations in both. These failures make deterministic coverage, not advertised cores, the upper bound on worker selection. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index f43712859d..0484548c76 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -26,7 +26,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 -一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: +一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: | Linux 完整主流程 | 4 核 | 8 核 | 16 核 | 32 核 | 64 核 | 96 核 | |---|---:|---:|---:|---:|---:|---:| @@ -40,13 +40,13 @@ Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站 |---|---:|---:|---:|---:|---:|---:| | 活动耗时 | 152 秒 | 104 秒 | 104 秒 | 92 秒 | 103 秒 | 110 秒 | -Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次[重新定向的生产验证](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29907581119/attempts/2)在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 +Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完整的外层清单同时启动。一次重新定向的生产验证在 173 秒内完成了单机 Windows 完整清单,其中包括覆盖率和快照回放,因此 Windows 继续采用合并执行方式。 -客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在[一次分支头精确的候选运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29912577681)中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次[无缓存的全规格运行轨迹](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29913033155)在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 +客户端包依赖图增大后,缓存机制和调度器压力也成为实测工作负载的一部分。在一次分支头精确的候选运行中,Linux 的仓库门禁耗时 39 秒,完整作业耗时 69 秒;Windows 的仓库门禁耗时 117 秒,完整作业耗时 228 秒。Windows pnpm 缓存的 154 MB 归档下载耗时约 2 秒,但解压耗时 27 秒,随后安装耗时 23 秒,作业结束后的保存又耗时 14 秒。一次无缓存的全规格运行轨迹在 27 秒内完成了同一台 32 核 Windows 运行器上的安装。因此,未来若要启用大型运行器,需要测量完整作业,而不能只测门禁耗时。 任何比较都必须计入主机设置。一个标准 Node 26 作业曾在总共 67 秒的耗时中,把 36 秒用在 `Set up job` 上;`actions/setup-node` 从托管 toolcache 找到 Node 后,仍花费 46.56 秒输出缓存的 Windows 环境详情。一个 Linux 候选作业还在注册 50 KB 的 Bubblewrap 包时耗时 18 秒,因为托管映像扫描了 202,507 个包数据库文件。[`scripts/prepare-ci-bubblewrap.sh`](../../../../scripts/prepare-ci-bubblewrap.sh) 改为验证固定版本的 payload 并将其解压到临时运行器目录,执行功能性隔离探针,并让这项准备工作与依赖安装重叠执行。 -内层与外层工作线程上限是相互独立的控制机制。一次[分支头精确、使用 32 个工作线程的 ESLint 实验](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29918329463)使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 +内层与外层工作线程上限是相互独立的控制机制。一次分支头精确、使用 32 个工作线程的 ESLint 实验使 lint 耗时增至 52.28 秒、覆盖率耗时增至 42.71 秒;同一次运行中,一项适配器空闲超时测试失败。后来一次同时运行 8 项门禁的运行轨迹将覆盖率耗时降至 35.17 秒,但生产网站构建被延后,直到聚合流程耗时达到 41.06 秒时才完成。因此,不能仅凭核心数照搬同等规模的工作线程上限。 进程约束的覆盖率项目恰好包含 5 个套件文件。32 个 fork 曾两次导致 Node 24 的 CJS 词法分析器崩溃,后来一次使用 16 个 fork 的运行又复现了工作进程丢失和无效的覆盖率结果。因此,单次 Vitest 调用会对大范围测试清单使用线程,只为涉及进程全局状态、`process` API 或对时间敏感的进程 I/O 的套件保留 fork。这份有限的 fork 清单包括本地 bash 进程通路套件和 pi-ai 适配器套件,因为聚合争用改变了二者的时序观测结果。这些故障表明,选择工作线程数量时,上限取决于能否得到确定的覆盖率结果,而非标称核心数。 diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index be725fc905..ac859f10dd 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.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 .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md -2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77 -2026-07-26-briefed-minimal-translation-updates.zh.md: c3c1b4e845b5a45acde55884b883b1dd1e570d77 +2026-07-26-briefed-minimal-translation-updates.md: afd990b7b63adfd0e66a4726975b678d044e7cad +2026-07-26-briefed-minimal-translation-updates.zh.md: dcdb253746041a7928ab7a544ae427293bc3f2de diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index a472513767..afd990b7b6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -12,7 +12,7 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged. -- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. +- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the incremental prompt-pipeline work; its provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer. - **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document. - **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index c3c1b4e845..dcdb253746 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 -- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 +- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了增量提示词流水线工作的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 - **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 - **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 4786909a7f..19945a6580 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.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 .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc -2026-07-27-wine-windows-gates-experiment.zh.md: 67f59a93d1b1fad98e36e6f9c51bc77abb9e3d07 +2026-07-27-wine-windows-gates-experiment.md: 1b01fe00dc1588482442a3cedc35eefa7fdb0975 +2026-07-27-wine-windows-gates-experiment.zh.md: b50739f5b63c5836248c94927037e38900abc84f diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 640c8e455b..1b01fe00dc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -14,7 +14,7 @@ The question the experiment answered: can a plain Linux runner produce an equiva The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push. -Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). +Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: an independent prototype kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; the prototype's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here). The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. @@ -32,7 +32,7 @@ Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubun **A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget. -**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. +**Windows pnpm performing the install under Wine.** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict. **Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane. diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 67f59a93d1..b50739f5b6 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -14,7 +14,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。 -依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 +依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了该原型的校验和固定,同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装契约在此仍由 Linux 侧验证)。 该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 @@ -32,7 +32,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo **在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。 -**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 +**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。 **Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 1a179748f9..77003539ac 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.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 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: de3cd052f94a0d5256c7687e9a1a38ee69fd2caf -2026-07-31-installer-adopts-existing-checkout.zh.md: 2e8be804b4af6151e77e36f8b109616aab3a18e9 +2026-07-31-installer-adopts-existing-checkout.md: 3a213a6232e57f305910240505983421dcd288ad +2026-07-31-installer-adopts-existing-checkout.zh.md: 7cdcd5549fb2bca2a6cb11f0cf2867687565ae79 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md index de3cd052f9..3a213a6232 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md @@ -46,6 +46,6 @@ A container adopting an outside clone is also no longer self-contained: deleting ## Testing -`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing. +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 2e8be804b4..7cdcd5549f 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -46,6 +46,6 @@ Status: implemented ## Testing -`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地,要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。 +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml index 32b51699e2..8ba8380bf1 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.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 .agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md -2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42 -2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc +2026-08-06-doc-site-carries-its-images.md: 4078a9b6251cf67456590ae25602a9f288c88dc1 +2026-08-06-doc-site-carries-its-images.zh.md: a9afb138d45d1ab991963b997e408477cf88110b diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md index 9109808874..4078a9b625 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.md @@ -20,7 +20,7 @@ Only a regular file whose real path stays inside the repository is copied; anyth `docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page. -`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text. +`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the GitHub-raw fallback points at the public source home, which keeps that seam honest for a consumer that only rewrites text. Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress. @@ -36,7 +36,7 @@ Canonical Markdown keeps writing ordinary repository-relative image paths, so th Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale. -Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets. +Images referenced from *unpublished* documents are untouched. A text-only projection resolves them against the public source home; a document that is not on the site has no site build to carry its assets. ## Testing diff --git a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md index d601112e88..a9afb138d4 100644 --- a/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-doc-site-carries-its-images.zh.md @@ -20,7 +20,7 @@ Status: implemented `docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。 -`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。 +`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时,GitHub raw 回退会指向公开源主页;这让该 seam 对只改写文本的消费方保持诚实。 正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。 @@ -36,7 +36,7 @@ Status: implemented 已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。 -**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw,对私有仓库仍然失败。今天没有任何消费方用到它们,而不在站点上的文档也没有站点构建可以承载其资源。 +**未发布**文档引用的图片不受影响。纯文本投影会相对于公开源主页解析它们;不在站点上的文档没有站点构建可以承载其资源。 ## Testing diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index fe97a7b717..202a27ed1e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.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 .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f -2026-06-19-acp-snapshot-tests.zh.md: e292dbf3bf3c4c5198dc77d122bb6b8c36014ebf +2026-06-19-acp-snapshot-tests.md: c7b95bd68027705b99d850d596405e56eea0dfca +2026-06-19-acp-snapshot-tests.zh.md: 43d43262684920cae5feedb5f2eb109db00f9f8c diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index e118ada582..c7b95bd680 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -80,6 +80,6 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index e292dbf3bf..43d4326268 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -80,6 +80,6 @@ Status: implemented ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821948f873..46ab8e892f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ env: jobs: - # FIXME: Re-enable the three hosted serial reference jobs before cutting a release. + # TODO(hosted-serial-ci): Re-enable the three hosted serial reference jobs before release. # The self-hosted standby remains active on every master push. # Three enterprise jobs isolate coverage, static analysis, and the diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts new file mode 100644 index 0000000000..664f20dfe6 --- /dev/null +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -0,0 +1,95 @@ +// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the +// composer's effort pane — the levels a settings profile declares are exactly +// what the picker offers, and picking one records it with the default route. +// Zero model calls: declaring, describing, and switching are settings/llm +// traffic only, so there is no fixture and a stray stream would fail loud. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** Starts the shipped default on this scenario's declared reasoning model. */ +const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + // The whole reasoning offer is the profile: key = selectable level, value + // = the wire spelling dispatch would send (`max: ultra` renames; the + // valueless `off` means "supported, send nothing"). The route sets no + // deployment default, so the pane leads with the provider-default entry. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ + id: 'acme-think', + name: 'Acme Think', + reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, + }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers exactly the declared levels and records the picked one', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning')) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /推理等级/ }).click() + + // Declared levels, nothing else: the provider-default entry (the route + // configures no `reasoning`), then Off/High/Max — minimal, low, medium, + // and xhigh were not declared and must not be offered. + const levels = page.getByRole('menuitemradio') + await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 }) + .toEqual(['Default', 'Off', 'High', 'Max']) + const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + // Picking a level is the same gesture that saves the default target, so + // the effort lands in the gateway's settings section beside the route. + await page.getByRole('menuitemradio', { name: 'High' }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('reasoningEffort: high') + await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('选择模型,当前 Acme Think,推理等级 High') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/declared-reasoning.overlay.yml b/apps/web/tests/declared-reasoning.overlay.yml new file mode 100644 index 0000000000..d90452178c --- /dev/null +++ b/apps/web/tests/declared-reasoning.overlay.yml @@ -0,0 +1,8 @@ +# The fixture-less web scaffold registers no adapter, so the shipped +# deepseek-official default would be a route nothing serves. This scenario +# starts the default on its own declared reasoning model so the effort pane +# describes that model from the first open. +- id: api-gateway + config: + provider: acme-gateway + model: acme-think diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 143bc0d4db..54cd15bf94 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -1,5 +1,6 @@ -// Web e2e scenario: the real host filters skill.list to the model-and-user -// intersection before the browser slash source renders candidates. A real +// Web e2e scenario: the real host serves every user-invocable skill to the +// browser slash source — user-only (disable-model-invocation) entries appear +// with their marker while user-disabled quadrants stay hidden. A real // chromium connects a fresh workspace seeded with all four policy quadrants; // no model call is issued, so a stray stream fails loud on the open LLM seam. import { mkdir, writeFile } from 'node:fs/promises' @@ -92,7 +93,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await scaffold?.close() }) - it('renders only the model-and-user intersection in slash candidates', async () => { + it('renders every user-invocable skill and marks the user-only entry', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy')) const input = page.locator('textarea').first() await input.fill('/policy') @@ -102,8 +103,10 @@ describe('web e2e: skill invocation policy through the real host', () => { { timeout: 10_000 }, ).toBe(1) + // The user-only quadrant is invocable here — its only entry point — and + // wears the user-only marker; both user-disabled quadrants stay hidden. + expect(await menu.getByRole('option', { name: /policy-user-only user-only · / }).count()).toBe(1) expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0) - expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0) expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts new file mode 100644 index 0000000000..2d5a039623 --- /dev/null +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -0,0 +1,148 @@ +// Web e2e scenario: a user invokes a disable-model-invocation skill through +// the composer (issue #1470). The entered `/name args` line claims into +// skill.invoke: the real host forwards the gesture as an ordinary user +// prompt, injects the rendered body as instructions context named after the +// skill, and starts a turn answered by the replay seam. The transcript shows +// the gesture bubble, the collapsed context-injection row, and the reply. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-user-invoke', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const SKILL_NAME = 'user-invoke-demo' +const ARGS_TEXT = 'and confirm the fixture wiring' +const REPLY = 'USER_INVOKE_REPLY acknowledged; following the injected skill.' + +async function seedUserOnlySkill(workspaceCwd: string): Promise { + const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), [ + '---', + `name: ${SKILL_NAME}`, + 'description: Prove user-explicit invocation of a model-hidden skill', + 'disable-model-invocation: true', + '---', + '', + 'Reply with the fixture acknowledgement line.', + '', + ].join('\n')) +} + +const REPLAY: ReplayOverrideDoc = [{ + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: REPLY }, + { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 16 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], +}] + +describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation through the composer', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let replayDir: string + let tripwire: ReturnType + + beforeAll(async () => { + replayDir = await mkdtemp(join(tmpdir(), 'dsh-skill-user-invoke-replay-')) + const replayOverride = join(replayDir, 'replay.override.json') + await writeFile(replayOverride, JSON.stringify(REPLAY)) + scaffold = await launchWebScaffold({ + replayFixture: join(replayDir, 'override-only.jsonl'), + replayOverride, + // Paced replay keeps the timing-derived chrome (TTFT / tok/s) present + // deterministically; instant playback races it in and out of the golden. + paceMs: 10, + }) + await seedUserOnlySkill(scaffold.workspaceCwd) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (replayDir !== undefined) { + await rm(replayDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'skill-user-invoke e2e cleanup failed') + }) + + it('claims /name args into a gesture bubble, an injection row, and a replayed answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-user-invoke')) + const composer = page.locator('textarea:enabled').last() + await composer.waitFor({ timeout: 15_000 }) + + // The menu lists the user-only skill (its only entry point) before enter. + await composer.fill(`/${SKILL_NAME}`) + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await expect.poll( + () => menu.getByRole('option', { name: new RegExp(SKILL_NAME) }).count(), + { timeout: 10_000 }, + ).toBe(1) + + await composer.fill(`/${SKILL_NAME} ${ARGS_TEXT}`) + await composer.press('Enter') + + // The gesture stays an ordinary user bubble (decorated /name token plus + // the trailing text), ahead of the injected context. + const bubble = page.locator('[data-ref-chip="skill"]').first() + await bubble.waitFor({ timeout: 15_000 }) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) + + // The rendered body arrives as a context-injection row named after the + // skill; expanding it reveals the canonical block, and + // the user's text is NOT folded into it. + const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) + await injectionRow.waitFor({ timeout: 15_000 }) + await injectionRow.click() + const injectionBody = page + .locator('[data-context-injection-body]') + .filter({ hasText: `` }) + await injectionBody.waitFor({ timeout: 10_000 }) + const injected = await injectionBody.textContent() + expect(injected).toContain('Reply with the fixture acknowledgement line.') + expect(injected).not.toContain(ARGS_TEXT) + await injectionRow.click() + + // The injection started a turn; the replay seam answers it. + await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/declared-reasoning/ui.expected.md b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md new file mode 100644 index 0000000000..810a6bf8b5 --- /dev/null +++ b/apps/web/tests/snapshots/declared-reasoning/ui.expected.md @@ -0,0 +1,7 @@ +- menu "模型与推理等级": + - menuitemradio "Default" [checked]: + - text: Default + - img + - menuitemradio "Off" + - menuitemradio "High" + - menuitemradio "Max" diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md index 11acc39ad0..ca9230b6f1 100644 --- a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md +++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md @@ -1,3 +1,4 @@ - listbox "Trigger suggestions": - text: Skills - option "policy-shared Available to both model and user invocation" [selected] + - option "policy-user-only user-only · Available only to user invocation" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md new file mode 100644 index 0000000000..c77081584a --- /dev/null +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "/user-invoke-demo and confirm the fixtur" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: /user-invoke-demo and confirm the fixture wiring {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Context injection user-invoke-demo": + - img + - img + - text: Context injection user-invoke-demo +- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "0% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 528714a527..781cb57a2e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,6 +38,7 @@ "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", + "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", @@ -57,6 +58,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts", + "tests/skill-user-invoke.e2e.ts", "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f4302984a8..b4a4e15dc5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -753,6 +753,22 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -800,12 +816,70 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } + +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' ``` -Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:120`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:142`](../packages/llm/llm-pi-ai/src/config.ts) ## `@deepseek-ai/dsh-llm-replay` @@ -1457,7 +1531,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:262`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -2049,7 +2123,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:58`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-str-replace-editor` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 7fd91874d3..5a43d60d96 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -677,7 +677,7 @@ A skill provider, runtime contribution, or provider-backed catalog may have chan 'skills/change'(): void ``` -Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:280`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6e75b2a345..c44efaafa4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1946,7 +1946,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise` to say which object shape a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 0b7684a953..f6989521d4 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 所校验的对象形状。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-harness/deepseek-harness) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 461d1ef4fc..e79d5dac2f 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.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 docs/core-data-structures/core.md -core.md: f7cf288715a3aec2f7037f12fc983e3172a77cef -core.zh.md: c17fd1335503c95e7f7f6f96cc286f567a8384e6 +core.md: dbd584f10b3daf873bc14210472efb6cd315717e +core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index f7cf288715..dbd584f10b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -530,8 +530,6 @@ The loop builds each request from logged state. `EpochHeader` records call confi On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index c17fd13355..1fe1616a0c 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -536,8 +536,6 @@ interface ToolSchema { 在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。 -FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 - ```ts type-equiv /** * Provider, model, reasoning effort, and sampling scalars of one conversation's diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 84451e275a..824eaf8588 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | -| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:280`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index 5c6f7d4630..2dfe08c2fd 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.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 docs/glossary.md -glossary.md: 0270a2d0dba558483e8e458a932a27b0151f2c93 -glossary.zh.md: c3584731cc08b23cf7f47c09620a77b7bff65689 +glossary.md: 16409517bff623a80d6e1e00888d95f63b42f780 +glossary.zh.md: fe3138ac81f2681d7fc69cc2aede96edeb7ec176 diff --git a/docs/glossary.md b/docs/glossary.md index 0270a2d0db..16409517bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -4,8 +4,6 @@ English | [中文](glossary.zh.md) Domain vocabulary for the DeepSeek Harness SDK uses one canonical term per concept. Terms link to their entries with standard Markdown anchors; implementation detail stays in package READMEs and Agent Notes. -FIXME(glossary-completeness): Expand this glossary before the first release so it covers the SDK's other core and capability subsystems, not only agent scope. - ## agent-scope - **scope** — the unit of per-agent registration: a contribution (tool, prompt section, variable, restriction, listener) is either *global* (visible to every agent) or *scoped* (owned by exactly one [scope key](#scope-key)). Two levels, flat: scoped registrations do not inherit down to subagents; subtree behavior is expressed with [lineage](#lineage) data, never scope structure. diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index c3584731cc..fe3138ac81 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -4,8 +4,6 @@ DeepSeek Harness SDK 的领域词汇为每个概念规定一个规范术语。各术语通过标准 Markdown 锚点链接到相应条目;实现细节留在各包的 README 与 Agent Note 中。 -FIXME(glossary-completeness): 首次发布前扩充本术语表,使其覆盖 SDK 的其他核心与能力子系统,而非仅限于 agent scope。 - ## agent-scope - **scope**:按 agent(智能体)划分的注册单位。一项贡献(工具、提示词片段、变量、限制、监听器)要么是*全局的*(对所有 agent 可见),要么是*带作用域的*(归属于恰好一个 [scope key](#scope-key))。只有两层,采用扁平结构:带作用域的注册不会向下继承给 subagent;子树行为通过 [lineage](#lineage) 数据表达,从不通过 scope 结构。 diff --git a/docs/module-graph.md b/docs/module-graph.md index b7902f98a9..ab3ae725ae 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -299,7 +299,6 @@ flowchart TD pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants - pkg_skill --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_base --> pkg_invariants @@ -373,6 +372,8 @@ flowchart TD pkg_system_prompt --> pkg_invariants pkg_system_prompt --> pkg_llm pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm pkg_web --> pkg_invariants pkg_web --> pkg_llm pkg_api_gateway --> pkg_client_connection @@ -1167,7 +1168,6 @@ flowchart TD | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | @@ -1205,6 +1205,7 @@ flowchart TD | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index db888b165e..38df7fb986 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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 docs/user/guide/providers.md -providers.md: 5fd11f3753468f9eccd318045b1ac5f6b3a21622 -providers.zh.md: 3e71e3fb2c64fb71fed196433175c860d3f6c817 +providers.md: 8b52044e64411e3081d56c1ee1849d0b24cd1cda +providers.zh.md: f4a42a4093d253b4b230e4a838ba275a0ce58ac9 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 5fd11f3753..8b52044e64 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them. @@ -79,9 +100,15 @@ A profile the adapter could not serve is refused **where it is written**: a hand ## The model catalog -A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit. +A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit — but once you declare the list, every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. -Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry. +Reshaping a few catalog models while keeping the rest is `modelOverrides`' job: it is keyed by catalog model id, takes the same fields a `models` entry does, and leaves the rest of the catalog serving untouched. An override naming a model the catalog does not describe — or set beside a `models` list, or on a custom provider — is refused rather than silently skipped. + +The configurable model fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no consumer and ride the installed entry. + +**Declare reasoning levels per model.** `reasoningEfforts` lists the levels a model offers: each key appears in the composer's effort picker, and its value is what dispatch sends on the wire — `high: high` passes the name through, `max: ultra` renames it for a gateway with its own vocabulary. A level you leave out is not offered. `off` is special: declared without a value, Off appears in the picker and selecting it sends nothing; left out entirely, the picker offers no Off and requests carry no off switch — the provider's own default decides. `reasoningEfforts: false` declares a non-reasoning model, which is also how you strip reasoning from a catalog model your gateway cannot serve. Without this field a custom model does not reason and a catalog model keeps its catalog levels. + +**Pick the reasoning dialect.** How a level travels — plain `reasoning_effort`, DeepSeek's `thinking: {type}` plus effort, and so on — is normally guessed from the endpoint URL, and a private gateway's URL says nothing, so a DeepSeek-style gateway would be spoken to in the OpenAI dialect. `compat.thinkingFormat` sets the dialect explicitly, and `compat.supportsReasoningEffort: false` holds the parameter back from an endpoint that rejects it; both work on the route (its models' default) or per model, for `openai-completions` routes only. A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once. @@ -114,6 +141,7 @@ If the provider a saved default names is later removed, the composer says **Sele - **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable. - **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries. +- **`UNSUPPORTED_REASONING_EFFORT`** — the request asked the model for a level it does not offer. Pick a level the composer lists for that model, or declare the missing one in the model's `reasoningEfforts`. - **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present. - **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 3e71e3fb2c..f4a42a4093 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -59,6 +59,16 @@ llm-pi-ai: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the catalog + # keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high + # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -66,11 +76,22 @@ llm-pi-ai: apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + # key = level offered in the picker, value = what goes on the wire; + # only off may leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。 @@ -79,9 +100,15 @@ settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上 ## 模型目录 -`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。 +`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑——但一旦声明了这份列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。 -可配置的只有 harness 会消费的四个字段:`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。 +就地重塑目录里的几个模型、保留其余,归 `modelOverrides` 管:它以目录模型 id 为键,接受与 `models` 条目相同的字段,目录的其余部分原样继续服务。覆盖若点名了目录没有描述的模型,或与 `models` 列表并存,或写在自定义提供方上,都会被拒绝,而不是被静默跳过。 + +可配置的模型字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有消费方,随内置目录条目走。 + +**按模型声明推理档位。** `reasoningEfforts` 列出模型提供的档位:每个键都会出现在输入框的档位选择器里,其值是分派在协议中实际发送的内容——`high: high` 原样透传名称,`max: ultra` 则为使用自有词汇的网关改名。没写的档位不会被提供。`off` 比较特殊:声明而不给值,选择器里会出现 Off,选中它时什么也不发送;完全不写,选择器不提供 Off,请求也不携带关闭开关——由提供方自己的默认行为决定。`reasoningEfforts: false` 声明一个不具备推理能力的模型,这也是从网关服务不了的目录模型上剥除推理的办法。不写这个字段,自定义模型不推理,目录模型保留目录给出的档位。 + +**选定推理方言。** 档位如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加档位,诸如此类——通常靠端点 URL 来猜,而私有网关的 URL 什么也说明不了,于是 DeepSeek 风格的网关只会收到 OpenAI 方言的请求。`compat.thinkingFormat` 用来显式指定方言,`compat.supportsReasoningEffort: false` 则让该参数不再发给拒绝它的端点;两者既可设在路由上(作为其模型的默认值),也可按模型设置,且仅适用于 `openai-completions` 路由。 两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`(262144)与 `defaultMaxTokens`(32768)。这两个数按定义就是猜测,所以它们是路由字段:网关服务的模型更小时改一次即可。 @@ -114,6 +141,7 @@ api-gateway: - **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。 - **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。 +- **`UNSUPPORTED_REASONING_EFFORT`** — 请求向模型要了一个它不提供的档位。从输入框为该模型列出的档位里挑一个,或把缺的那个声明进该模型的 `reasoningEfforts`。 - **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api`、`baseURL`、`models` 是否齐全。 - **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 27592cdb74..aefb76991f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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 docs/user/guide/quickstart.md -quickstart.md: 72cc5a52c33faf81098f747799692b384fcd5f1a -quickstart.zh.md: ebb831c1e1bec02117c78a4c2426a84af3bb9478 +quickstart.md: 8b84017ad33bf02579891bc4dcf83eaf7ec39022 +quickstart.zh.md: dfb24f8fa194866908406c709d059bf1f6595d59 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 72cc5a52c3..8b84017ad3 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index ebb831c1e1..dfb24f8fa1 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API key ```sh -git clone https://github.com/deepseek-harness/deepseek-harness.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index bf4f7ac972..386926e51b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -90,8 +90,8 @@ async function prepareFsSearchWorkspace(cwd: string): Promise { } } -// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite; -// this ACP suite should eventually retain only automation-protocol contracts. +// TODO(acp-snapshot-ownership): Move backend/product scenarios to headless while +// retaining ACP protocol contracts here. function fixtureRecords(name: string): unknown[] { return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8') diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index f30dc715cf..ec369b492a 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -5,7 +5,7 @@ {"type":"step/start","seq":3,"time":1785498773754,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498773754,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"0ca31b92-27ac-451d-98d3-d1e5f605454b"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785498773755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3fc7e2f8-90fc-496c-b516-700cef1d86f1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1785730426818,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"60880315-9799-44c8-8a99-e6fe9ee5bdc5"},"surfaceOp":"append"} {"type":"session/title","seq":7,"time":1785730426818,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":8,"time":1785498773756,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":9,"time":1785730426819,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f89035cfcc..7e8e5248e4 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.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 examples/mcp-memory/README.md -README.md: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf -README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 +README.md: 792bb31b668b427c8734286878a9ec98071190d8 +README.zh.md: 51020f5288c4fbd245914280b8e7e4772e8cad69 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 023e6aefce..792bb31b66 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -36,7 +36,7 @@ Without a repository checkout, download the selected overlay directly: mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 44ace680cd..51020f5288 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -36,7 +36,7 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" mkdir -p "${DSH_HOME:-$HOME/.dsh}" curl --fail --location \ --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml + https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" ``` diff --git a/package.json b/package.json index def6e5bdcb..981ce68707 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "doc-typecheck": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", + "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 776d21fd46..a6cf9c1d7c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2449,7 +2449,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (missing !== undefined) return missing return ok(request, { skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' }, + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, ], }) }, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc1062843e..cc4504e538 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -163,6 +163,7 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5d231b6bd8..6f46a510c1 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -83,6 +83,9 @@ export function contextProvenance(source: unknown): ContextProvenanceView { return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind } case 'plugin': return { role: 'inject', label: readString(record, 'plugin') ?? kind } + // A user-explicit skill invocation names the skill it injected. + case 'skill-invocation': + return { role: 'inject', label: readString(record, 'name') ?? kind } // Documented default arm of the merge-extensible source map: an unknown // producer still identifies itself by its own durable kind. default: diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index b596c681a9..ae7643f0de 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -57,10 +57,11 @@ function materializeNode( stepTimings: ReadonlyMap, ): ConversationNode { switch (event.type) { - case 'user/message': - // Injected context (plugin/goal source) folds to a context node, not a - // user message; only a direct human prompt is a user node. A compaction - // checkpoint never reaches here (isCompactCheckpoint routes it away). + case 'user/message': { + // Injected context (plugin/goal/skill-invocation source) folds to a + // context node, not a user message; only a direct human prompt is a + // user node. A compaction checkpoint never reaches here + // (isCompactCheckpoint routes it away). if (event.data.source.kind !== 'user') { return { kind: 'context', seq: event.seq, time: event.time, @@ -80,6 +81,7 @@ function materializeNode( kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, } + } case 'assistant/message': return { kind: 'assistant', seq: event.seq, time: event.time, diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 3698f62aab..9da8755b2d 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -19,7 +19,7 @@ import type { Context } from 'cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, - SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike, + SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike, } from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -35,16 +35,11 @@ export interface RootOwnerProps { children?: never } /** Instance key for root-scoped store records (session records key by session id, so the literal cannot collide). */ const ROOT_INSTANCE_KEY = 'root' -// FIXME(slot-parity): the engine's arbitrated persist extensions — create() -// takes the scope key (per-session localStorage suffix) and instances expose -// clearPersisted() — are not yet on ui-slots' StoreHandle/StoreInstanceLike; -// these local structural faces bridge until fw-slots lifts them. +/** Canonical type-erased store handle used by the runtime lifecycle map. */ +type EngineStoreHandle = Exclude -/** Store handle face as the engine actually ships it (scope-key-aware create). */ -interface EngineStoreHandle { create(scopeKey?: string): EngineStoreInstance } - -/** Engine instance face: the host-contract shape plus persisted-state cleanup. */ -interface EngineStoreInstance extends StoreInstanceLike { clearPersisted(): void } +/** Canonical engine instance derived from the handle's create contract. */ +type EngineStoreInstance = ReturnType /** Store axis record: one per live handle, dropped when the last holding entry unloads. */ interface StoreAxisRecord { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b6f2884837..2f4299ce6c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -198,6 +198,7 @@ export class FakeApiClient implements IApiClient { onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) + readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)), diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts index 626c7792f7..05dc2855ca 100644 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ b/packages/client/runtime/tests/transcript-adapter.spec.ts @@ -164,6 +164,28 @@ describe('TranscriptAdapter', () => { expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) }) + it('materializes a skill-invocation injection as a named instructions context', () => { + const adapter = new TranscriptAdapter() + adapter.reset([ + at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo check the fixture' }], + source: { kind: 'user' }, + }) }), + at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ + content: [{ type: 'text', text: 'body' }], + source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, + }) }), + ]) + const nodes = adapter.nodes() + // The gesture stays a user bubble; the injected body folds to a context + // row named after the skill, presented as instructions. + expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) + expect(nodes[1]).toMatchObject({ + provenance: { role: 'inject', label: 'hidden-demo' }, + form: 'instructions', + }) + }) + it('skips events core does not call surface-eligible, marker or not', () => { // The transcript is the append-origin surface, so log-only events (a chunk, // a turn boundary, a compact/* provenance record) and a future type core diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5473c9f8a2..30b51b3870 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -137,29 +137,27 @@ function TurnErrorItem({ node, t }: { /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * logged model text remains the single truth; this is presentation only. + * Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21: + * the sent text IS the reference — the bubble uses the same plainest token + * scan as the composer, minus the lexicon: sent tokens were validated at + * compose time, so shape alone decorates). */ function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g + const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - const legacy = m[1] !== undefined - const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' + const tokenStart = m.index + (m[1]?.length ?? 0) + const label = m[2] ?? '' if (tokenStart > cursor) parts.push() parts.push( {label} , ) - cursor = legacy ? m.index + m[0].length : tokenStart + label.length + cursor = tokenStart + label.length } if (parts.length === 0) return if (cursor < text.length) parts.push() diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 7d5ba6a528..41cbd61afc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -871,6 +871,7 @@ describe('MessageItem arms', () => { view.rerender() expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') }) + }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..845e6099c8 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,6 +73,7 @@ describe('producedForClosing derivation', () => { expect(producedForClosing(nodes, 999)).toEqual([]) }) + it('counts a generic edit and never spills across the turn boundary', () => { const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ ...toolResult(seq, callId, 'str_replace_editor'), diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index ca4bc68ebf..a1d1a2c9d5 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/README.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 packages/client/ui-skill/README.md -README.md: f70bd2780f255cd8e0c64acb3da3863e10c4fa9d -README.zh.md: 6eb6cbd3ae196a540e161a3a23f9df2136824f2e +README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd +README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9 diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index f70bd2780f..bdd772662a 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path inserts a model reference rather than loading the body directly. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `name` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`. + +A pick lands the literal `/name ` text and the prompt ships the same literal (decision 21) — this source implements no adjudication hooks and no reference codec (the legacy `name` form is gone with the removal cut). Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every front end, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states. @@ -14,23 +16,22 @@ The browser plugin also registers a keyed `skill` toolview in `conversation.chat ## Model Experience -### Skill reference text in the user prompt +### User-explicit skill invocation #### What the model sees -A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it. +The user's message reaches the model verbatim, `/name` literal included. The host's pre-step boundary (`dsh-tool-skill`) then appends the canonical `` block — the same `renderSkillContent` output the `skill` tool returns — as injected instructions context at the end of that step's injections, closest to the model's answer. Loading is deterministic: the model receives the full body without being asked to call the `skill` tool, and the catalog tells it not to re-load an inline-injected skill. #### Token effect -Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens. +One invocation adds the rendered skill body to that turn as injected context — the same cost as the model loading the skill through the tool, paid unconditionally instead of at the model's discretion. Menu browsing and the candidate fetch add zero model tokens. #### KV Cache effect -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. +Append-only: the injected message lands after the reusable history prefix. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it. -- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change. -- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog. -- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item). +- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference, and the host gesture boundary judges the sent text, not the menu interaction. Chip visuals derive from the lexicon scan; no occurrence identity, position tracking, or structured reference payload on the prompt wire (both are ledger items). +- **A menu opened before the prewarm settles** shows no skill candidates for that keystroke; the next keystroke re-polls the settled cache. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 6eb6cbd3ae..959ff0ede6 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径插入的是模型引用,而不是直接加载正文。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `name`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 + +pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21)——本 source 不实现任何裁决钩子,也没有引用 codec(旧的 `name` 形式已随移除裁定消失)。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每一种前端注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 `skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。 @@ -14,23 +16,22 @@ skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` sourc ## 模型体验 -### 用户提示词中的 skill 引用文本 +### 用户显式 skill 调用 #### 模型看到的内容 -被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21:纯文本,无 `` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它。 +用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `` 块——与 `skill` 工具返回的 `renderSkillContent` 输出相同——作为注入的指令上下文追加在该步骤各项注入的末尾,最贴近模型的回答。加载是确定性的:模型无需被要求调用 `skill` 工具就能收到完整正文,目录也会告诉它不要重新加载已内联注入的 skill。 #### Token 影响 -有条件且极小:只有 pick(或手动键入相同文本)会把引用的字符加进那一条用户消息。浏览菜单和拉取候选不会增加任何模型 token。 +一次调用会把渲染后的 skill 正文作为注入上下文加进该轮次——成本与模型经由工具加载该 skill 相同,只是无条件支付,而非由模型自行裁量。浏览菜单和拉取候选不会增加任何模型 token。 #### KV Cache 影响 -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 +仅追加:注入的消息落在可复用历史前缀之后。该包绝不改写较早的请求 token。 ## 已知限制与暂缓事项 - **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。 -- **skill 加载具有非确定性**:引用是协作线索,不是保证;模型可能忽略它。针对命中率不足情况的返工路径(host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。 -- **首次击键可能与预热竞速**:scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍:skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。 -- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。 +- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用,宿主手势边界评判的是发出的文本,而不是菜单交互。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份、位置跟踪,也没有提示词协议上的结构化引用载荷(两者都是台账事项)。 +- **预热落定之前打开的菜单**:在那次击键下不显示 skill 候选;下一次击键会重新轮询已落定的缓存。 diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9631125801..4e23be06be 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -2,13 +2,16 @@ * Skill reference plugin, browser half: registers the '/' skill source — * candidates from the skill.list RPC addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host - * resolves cwd from the session header), pick inserts the literal `/name ` - * text (decision 21: the draft carries plain text, chip visuals are derived - * by scanning against the source lexicon, and the prompt ships the same - * literal — no `` tag). The RPC rides the plugin's root-context - * connection captured at registration — the source never reads services off - * a per-call argument. No adjudication hooks: skill references ride - * ordinary prompts and never enter command adjudication. + * resolves cwd from the session header). A pick lands the literal `/name ` + * text and the prompt ships the same literal (decision 21); determinism + * lives host-side — the pre-step boundary (`dsh-tool-skill`) recognizes a + * leading `/name` naming a user-invocable skill and injects the rendered + * body for every front end, including `disable-model-invocation` skills the + * model-side catalog never lists (issue #1470). The RPC rides the plugin's + * root-context connection captured at registration — the source never reads + * services off a per-call argument. Draft chip visuals still derive from + * the lexicon scan; the legacy `` reference codec is gone (decision + * 21 removal cut). * * Catalog fetches are cached per session (the small twin of the ui-command * directory): the per-keystroke candidates re-poll filters a settled @@ -119,6 +122,10 @@ export function apply(ctx: ClientContext): void { for (const key of [...fetches.keys()]) invalidate(key) } + // The bound translate resolves against the registered dictionaries with the + // locale service's own fallback ladder; candidate-time reads stay plain text. + const t = ctx.locale.bind(NS) + const source: SlashSource = { trigger: '/', name: 'skill', @@ -129,7 +136,12 @@ export function apply(ctx: ClientContext): void { if (signal.aborted) return [] return skills .filter(skill => skill.name.startsWith(query)) - .map(skill => ({ name: skill.name, description: skill.description })) + .map(skill => ({ + name: skill.name, + // The user-only marker rides the description (the menu's only + // secondary text); `hint` is the claim-state ghost text, not a badge. + description: skill.modelInvocable ? skill.description : `${t('menu.userOnly')} · ${skill.description}`, + })) }, warm(session) { // Fire-and-forget scope-birth prewarm; the shared fetch reports @@ -150,16 +162,14 @@ export function apply(ctx: ClientContext): void { } }, onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } } + // Decision 21: the pick lands plain text and the prompt ships the same + // literal. Determinism no longer rides the client — the host's + // pre-step boundary (dsh-tool-skill) recognizes the leading /name and + // injects the rendered body for every front end. A name shared with a + // host command still resolves to the command: adjudication claims the + // line client-side before it ever becomes a prompt. return { text: `/${candidate.name} ` } }, - codec: { - clipboardText: ref => `/${ref}`, - serialize: ref => Promise.resolve(`${ref}`), - }, } const slash = ctx.get('slash') as SlashServiceContract ctx.on('connection/reset', clearAll) diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index 53746397bc..40ef78dea5 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', } satisfies Record /** The skill namespace key union. */ @@ -20,4 +21,5 @@ export const en = { 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', } satisfies Record diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 9b047a3713..f73a8d8bda 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -20,11 +20,15 @@ import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client- import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' -type SkillRow = { name: string; description: string; whenToUse?: string } +type SkillRow = { name: string; description: string; whenToUse?: string; modelInvocable?: boolean } type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> +type InvokeResult = + | { ok: true; value: { accepted: true } } + | { ok: false; error: { code: string; message: string; details: object } } +type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> interface PresentationCapture { slots: SlotsService @@ -49,16 +53,19 @@ function providePresentation(ctx: Context): PresentationCapture { capture.dictionaries.push({ namespace, dictionaries }) return () => { capture.localeDisposed = true } }, + // Minimal bound-translate fake: zh dictionary lookup, key passthrough on miss. + bind: () => (key: string) => key === 'menu.userOnly' ? '仅用户' : key, }) return capture } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId) { +async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { const ctx = new Context() let captured: SlashSource | undefined ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('connection', { api: { skills: { list } } }) + const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) + ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } @@ -70,9 +77,9 @@ async function bench(list: ListFn, addressed?: SessionId) { } const CATALOG: SkillRow[] = [ - { name: 'commit-helper', description: 'commit flow' }, - { name: 'code-review', description: 'review flow', whenToUse: 'reviews' }, - { name: 'deploy', description: 'deploy flow' }, + { name: 'commit-helper', description: 'commit flow', modelInvocable: true }, + { name: 'code-review', description: 'review flow', whenToUse: 'reviews', modelInvocable: true }, + { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) @@ -117,12 +124,14 @@ describe('apply', () => { 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', + 'menu.userOnly': '仅用户', }, en: { 'row.running': 'Loading skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', + 'menu.userOnly': 'user-only', }, }, }]) @@ -313,8 +322,8 @@ describe('lexicon', () => { }) }) -describe('pick and codec', () => { - it('onPick returns the literal /name text with a closing space (decision 21)', async () => { +describe('pick lands plain text (decision 21)', () => { + it('onPick returns the literal /name text with a closing space', async () => { const { source } = await bench(listOk(CATALOG)) const outcome = source.onPick({ candidate: { name: 'commit-helper', description: 'commit flow' }, @@ -326,18 +335,27 @@ describe('pick and codec', () => { expect(outcome).toEqual({ text: '/commit-helper ' }) }) - it('codec projects clipboard `/name` and serializes the model form name', async () => { - const { source } = await bench(listOk(CATALOG)) - expect(source.codec!.clipboardText('deploy')).toBe('/deploy') - await expect(source.codec!.serialize('deploy', new AbortController().signal)) - .resolves.toBe('deploy') - }) -}) - -describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => { + it('keeps the legacy reference codec removed and stays out of adjudication', async () => { const { source } = await bench(listOk(CATALOG)) + // Determinism lives host-side (the pre-step gesture boundary), so the + // source neither claims lines nor serializes reference markup. + expect(source.codec).toBeUndefined() expect(typeof source.matchSpace).toBe('undefined') expect(typeof source.matchEnter).toBe('undefined') }) }) + +describe('user-only marking', () => { + it('prefixes the description of candidates the model cannot invoke', async () => { + const rows: SkillRow[] = [ + { name: 'shared-skill', description: 'both surfaces', modelInvocable: true }, + { name: 'user-only-skill', description: 'user surface only', modelInvocable: false }, + ] + const { source } = await bench(listOk(rows)) + const candidates = await source.candidates(proj('s1'), req('')) + expect(candidates).toEqual([ + { name: 'shared-skill', description: 'both surfaces' }, + { name: 'user-only-skill', description: '仅用户 · user surface only' }, + ]) + }) +}) diff --git a/packages/cordis/tool-cordis/README.i18n.yaml b/packages/cordis/tool-cordis/README.i18n.yaml index 0a55bfc7f6..fd2b5443ef 100644 --- a/packages/cordis/tool-cordis/README.i18n.yaml +++ b/packages/cordis/tool-cordis/README.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 packages/cordis/tool-cordis/README.md -README.md: eda135d93e2912bbb4e111af40d176409b383b5b -README.zh.md: 773d4100f1be6c54f491838b65205b85ec61cdbc +README.md: 9986310160c2b56126155a4c3ef84d66018d31c2 +README.zh.md: d955306e1e5d4154f58c771704782ece44a15c99 diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index eda135d93e..9986310160 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -85,5 +85,5 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi ## Known Limitations and Deferred Work - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). -- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` cover every mount seen so far, and a guarded `effect` waits on a real need (`FIXME(sandbox-effect)`). +- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. diff --git a/packages/cordis/tool-cordis/README.zh.md b/packages/cordis/tool-cordis/README.zh.md index 773d4100f1..d955306e1e 100644 --- a/packages/cordis/tool-cordis/README.zh.md +++ b/packages/cordis/tool-cordis/README.zh.md @@ -85,5 +85,5 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 ## 已知限制与暂缓事项 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 -- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 已覆盖目前出现的每项挂载,受保护的 `effect` 会等待真实需求(`FIXME(sandbox-effect)`)。 +- **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 595dc462e0..22d85936f4 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -749,7 +749,6 @@ export function isPlugin(value: unknown): value is Plugin { * @param plugin - the plugin the mount code returned. * @returns an equivalent plugin whose `apply` sees the sandbox context façade. */ -// FIXME(sandbox-effect): expose guarded custom effects when a mount needs bespoke cleanup. export function guardedPlugin(plugin: Plugin): Plugin { if (typeof plugin === 'function') { const functionPlugin = plugin as (ctx: Context, config?: unknown) => unknown diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 87fd17c36b..3224487b60 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -527,6 +527,7 @@ describe('dsh-agent-spine-demo bundle', () => { If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill. ", "type": "user/message", }, diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 1da2771257..244abae423 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -225,7 +225,7 @@ describe('hooks-claude bridge — PostToolUse', () => { expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true) }) - it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => { + it('a PreToolUse permissionDecision:ask fails closed without an approval service', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-')) dirs.push(dir) const s = join(dir, 'ask.sh') @@ -241,7 +241,7 @@ describe('hooks-claude bridge — PostToolUse', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError. + // No approval service is mounted, so `ask` fails closed: the tool does not run and the result is isError. expect(ran).toBe(false) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 0a0131d292..961b48dd0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 7ac7bdc6db2e2abbc60d1a8813e229c21ed39fe7 -README.zh.md: d6ece5caed752cf0cc59cc97017549ec2b1e66cb +README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f +README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7ac7bdc6db..5506cbef7b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -46,7 +46,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index d6ece5caed..de31f65394 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -46,7 +46,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的入口。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此每一种前端(web、TUI、ACP、手动键入的文本)共享同一条确定性路径,没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e4b715a0c4..cfcae423bc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, @@ -1246,6 +1247,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return llm === undefined || llm.listProviders().some(entry => entry.id === provider) } + /** + * Resolve the addressed agent for a turn-starting method and refuse when no + * adapter serves its current route: a route nothing serves cannot start a + * turn, and letting it try spends the whole pre-step path to fail inside + * the adapter with a message about registration. Refusing here names the + * model the session is pointed at while the draft is still in the composer. + * This is `session.prompt`'s enforcement boundary: a client that disables + * its input is an affordance, and the method stays callable regardless. + */ + async function turnAgentFor( + request: RpcRequest, sessionId: SessionId, + ): Promise<{ agent: Agent } | { refused: RpcResponse }> { + const found = await agentFor(sessionId) + if ('error' in found) return { refused: err(request, found.error) } + const agent = found.agent + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return { + refused: err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }), + } + } + return { agent } + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1782,23 +1811,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async prompt(request) { const { sessionId, mode, content } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const agent = found.agent - // A route no adapter serves cannot start a turn, and letting it try - // spends the whole pre-step path to fail inside the adapter with a - // message about registration. Refusing here names the model the - // session is pointed at while the draft is still in the composer. - // This is the enforcement boundary: a client that disables its input - // is an affordance, and this method stays callable regardless. - const target = targetFor(agent).current - if (!routeServed(target.provider)) { - return err(request, { - code: 'model-unavailable', - message: `no adapter serves provider "${target.provider}"; select a model for this session`, - details: { provider: target.provider, model: target.model }, - }) - } + const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) + if ('refused' in resolved) return resolved.refused + const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2359,13 +2374,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} }) } try { - const skills = (await skillRegistry.list({ cwd })) - .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable) + const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable) return ok(request, { skills: skills.map(skill => ({ name: skill.name, description: skill.description, ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, })), }) } catch (error: unknown) { diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts index 3bf7ad429a..747bf19bad 100644 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ b/packages/host/apiproxy/src/api/skills.schema.ts @@ -14,6 +14,7 @@ export const skillEntrySchema = z.object({ name: z.string().min(1), description: z.string(), whenToUse: z.string().optional(), + modelInvocable: z.boolean(), }) satisfies z.ZodType> /** skill.list request payload. */ diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts index 33802dd4c0..3b3e711a93 100644 --- a/packages/host/apiproxy/src/api/skills.ts +++ b/packages/host/apiproxy/src/api/skills.ts @@ -10,16 +10,24 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' /** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */ export interface SkillEntry { - /** Kebab-case identifier referenced as `name` in prompts. */ + /** Kebab-case identifier the user references as `/name` in the composer. */ readonly name: string /** Short routing description. */ readonly description: string /** Optional extra routing guidance. */ readonly whenToUse?: string + /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */ + readonly modelInvocable: boolean } -/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */ +/** + * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing + * is the domain's only RPC: invocation itself is a plain `session.prompt` + * whose leading `/name` token the host recognizes at the pre-step boundary + * (`dsh-tool-skill` injects the rendered body there), so every client shares + * one deterministic path with no dedicated invocation wire. + */ export interface SkillsApi { - /** Lists skills usable by the browser's user-selected model-reference path. */ + /** Lists the user-invocable skill catalog for the session's project. */ list(request: RpcRequest<{ sessionId: SessionId }>): Promise> } diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 55781a3e77..09526c5a87 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -228,7 +228,10 @@ describe('skill.list', () => { // touch (or resume through) the Agent registry. const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj' } }) const value = expectOk(await api.skills.list(request({ sessionId: session.id }))) - expect(value.skills).toEqual([{ name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }]) + expect(value.skills).toEqual([ + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'user-only', description: 'User-only', modelInvocable: false }, + ]) expect(seenCwds).toEqual(['/proj']) expect(ctx.agents.get(session.id)).toBeUndefined() }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 22e1650f5b..6481d75837 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -196,7 +196,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, skills: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, }, goals: { @@ -381,7 +381,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const miss = await c.commands.execute({ sessionId: 's' as never, line: '/nope' }) expect(miss.result).toEqual({ ok: true, value: { matched: false } }) const skills = await c.skills.list({ sessionId: 's' as never }) - expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } }) + expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } }) }) it('lets command.execute finish after the 30-second default unary deadline', async () => { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 28f9138502..75b6dff3f7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -395,12 +395,15 @@ describe('skills domain schemas', () => { expect(() => skillListRequestSchema.parse({})).toThrow() expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([]) const value = skillListValueSchema.parse({ skills: [ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing' }, - { name: 'bare', description: 'No guidance' }, + { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, + { name: 'bare', description: 'No guidance', modelInvocable: false }, ] }) expect(value.skills[0]?.whenToUse).toBe('when committing') expect(value.skills[1]?.whenToUse).toBeUndefined() - expect(() => skillEntrySchema.parse({ name: '', description: 'd' })).toThrow() + expect(value.skills[1]?.modelInvocable).toBe(false) + expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow() + // modelInvocable is required wire data: an entry without it fails. + expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() }) }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 5dbff068d0..790989c5d6 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.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 packages/llm/llm-pi-ai/README.md -README.md: d0968a7366943a36ba428355058ad14169884c0a -README.zh.md: d4c401698071bd494b95fc993b42de8b3197edc4 +README.md: eb67ce889193aadbd694d7aae53e47c7d20703be +README.zh.md: b4b3e3c208702fa10e5f434a70608702d0576fbd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d0968a7366..eb67ce8891 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -35,6 +35,15 @@ Configure credentials, the model catalog, and deployment-specific transport sett models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -42,18 +51,43 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`. ## Catalog resolution -A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, and no listing endpoint reports a model's reasoning protocol, so reasoning rides the installed catalog entry or is absent. +A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. + +`modelOverrides` reshapes individual installed-catalog models without that cost: each key is a catalog model id, each value the same fields a `models` entry takes with the id living in the key, and the rest of the catalog keeps serving untouched — "correct one model, keep the other thirty-seven" as a three-line edit. An override becomes that catalog entry's configuration, so capacities, efforts, and compat resolve through the same path with the same diagnostics and the same request-default semantics as a `models` entry. Overrides are only meaningful on a catalog route serving its catalog: one set beside a `models` list (which already replaces the catalog), on a hand-declared route (whose models are fully spelled in `models`), or naming a model the catalog does not describe is refused rather than skipped, because a silently unchanged model is a typo someone would otherwise hunt for. + +### Per-model reasoning efforts + +`reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps the installed catalog entry's capability (a hand-declared model has none and does not reason); `false` declares a non-reasoning model, which is how a profile strips reasoning from a catalog model its gateway cannot serve; an empty declaration is refused rather than guessing between those two meanings. + +The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. + +### Reasoning-dispatch compat switches + +How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. @@ -71,11 +105,11 @@ Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.cr The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own. -A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. +A model that carries reasoning metadata — from the installed catalog or from its entry's `reasoningEfforts` — exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. -Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -152,6 +186,7 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer. +- **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work). - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one. - **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index d4c4016980..b4b3e3c208 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -35,6 +35,15 @@ models: - id: claude-sonnet-4-5 contextWindow: 200000 + # Catalog route with one model reshaped in place; the rest of the + # catalog keeps serving (a models list would replace it instead). + deepseek: + apiKeyEnv: DEEPSEEK_API_KEY + modelOverrides: + deepseek-v4-pro: + reasoningEfforts: + off: + high: high # Hand-declared route: pi-ai ships nothing under this key, so the profile # supplies the whole provider. acme-gateway: @@ -42,18 +51,43 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 + # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + compat: + thinkingFormat: deepseek models: - id: acme-large name: Acme Large contextWindow: 65536 maxTokens: 4096 + - id: acme-think + name: Acme Think + contextWindow: 262144 + maxTokens: 32768 + # key = selectable level, value = its wire spelling; only off may + # leave the value empty (supported, send nothing). + reasoningEfforts: + off: + high: high + max: ultra ``` 字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。 ## Catalog 解析 -profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。 +profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。 + +`modelOverrides` 无需这份代价就能就地重塑单个已安装 catalog 模型:每个键是一个 catalog 模型 id,每个值可写 `models` 条目接受的同一批字段,只是 id 落在键上,而 catalog 的其余部分原样继续服务——「改一个模型、其余三十七个原样保留」只是一次三行编辑。一条覆盖会成为该 catalog 条目的配置,因此容量、档位与 compat 沿与 `models` 条目相同的路径解析,携带相同的诊断与相同的请求默认值语义。覆盖只在正服务自身 catalog 的 catalog 路由上才有意义:与 `models` 列表并存的一份(该列表本就替换了 catalog)、落在手工声明路由上的一份(其模型已在 `models` 中完整写出),或点名了 catalog 未描述模型的一份,都会被拒绝而非跳过,因为一个静默保持原样的模型,就是一个否则要有人费力追查的笔误。 + +### 按模型的推理档位 + +`reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。 + +该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 + +### 推理分派的 compat 开关 + +思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。 @@ -71,11 +105,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。 -携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 +携带推理元数据的模型——来自已安装 catalog,或来自其条目的 `reasoningEfforts`——会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 -受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。 @@ -152,6 +186,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 +- **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以休眠方式挂载该适配器),且 `models` 列表是数组、整体替换,这是体制内的出口。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 - **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。 - **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 173b84dd7d..8f1bc1a43c 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -14,7 +14,15 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' -import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai' +import type { + Api, + Model, + ModelCost, + ModelThinkingLevel, + OpenAICompletionsCompat, + Provider, + ThinkingLevelMap, +} from '@earendil-works/pi-ai' /** * Pricing for a model the installed catalog does not describe. The harness @@ -30,6 +38,58 @@ const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } */ const TEXT_ONLY: Model['input'] = ['text'] +/** + * Every pi-ai thinking level, in pi-ai's canonical escalation order. The + * `Record` key type is a drift gate: a pi-ai upgrade that adds or removes a + * level fails compilation here naming the drifted key, instead of silently + * narrowing what a profile may declare. + */ +const THINKING_LEVEL_GATE: Record = { + off: true, + minimal: true, + low: true, + medium: true, + high: true, + xhigh: true, + max: true, +} + +/** Every pi-ai thinking level a profile may declare, in escalation order. */ +export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] + +/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ +type PiThinkingFormat = NonNullable + +/** + * pi-ai thinking formats a profile cannot name: both drive the request through + * `chatTemplateKwargs`, which this configuration does not expose, so offering + * them would hand back a format with nothing to say. + */ +type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' + +/** One reasoning-dispatch wire format a profile may name. */ +export type PiAiThinkingFormat = Exclude + +/** + * The nameable reasoning-dispatch formats, most-reached first. The `Record` + * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added + * `baseten`) fails compilation here until the format is classified as offered + * here or withheld above, so the offer never silently lags the upstream set. + */ +const THINKING_FORMAT_GATE: Record = { + 'openai': true, + 'deepseek': true, + 'openrouter': true, + 'together': true, + 'zai': true, + 'qwen': true, + 'string-thinking': true, + 'ant-ling': true, +} + +/** Reasoning-dispatch wire formats a profile may name, most-reached first. */ +export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] + let providerIndex: Map | undefined /** @@ -71,6 +131,32 @@ export function catalogModels(provider: string): Map> { return new Map(models.map(model => [model.id, model])) } +/** + * Selectable reasoning efforts for one model: each key is a level the model + * offers (and selectors show), and its value is the wire spelling dispatch + * sends for it. `off` alone may leave its value empty — "supported, send + * nothing" — because for most providers not thinking is the parameter's + * absence; every other declared level must name a wire value. A level absent + * from the dict is not offered. + */ +export type PiAiReasoningEfforts = Partial> + +/** + * Reasoning-dispatch compatibility switches, set on the route (its models' + * default) or per model (winning over the route). Only the switches pi-ai's + * reasoning dispatch reads are offered; the rest of pi-ai's compat surface + * keeps its baseURL-derived auto-detection. pi-ai types both fields only on + * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning + * shape in the protocol itself — so resolution rejects a model-level switch + * anywhere else, while a route-level default skips past models it cannot fit. + */ +export interface PiAiCompatProfile { + /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + thinkingFormat?: PiAiThinkingFormat + /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + supportsReasoningEffort?: boolean +} + /** One configured model entry: an id plus the catalog fields it overrides. */ export interface PiAiModelProfile { /** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */ @@ -86,8 +172,27 @@ export interface PiAiModelProfile { * default on its own. */ maxTokens?: number + /** + * Selectable reasoning efforts. Absent inherits the installed catalog + * entry's capability (a hand-declared model has none and does not reason); + * `false` declares a non-reasoning model, which is how a profile strips + * reasoning from a catalog model its gateway cannot serve; a non-empty dict + * declares the offered levels and their wire spellings. + */ + reasoningEfforts?: false | PiAiReasoningEfforts + /** Reasoning-dispatch switches for this model, winning over the route's. */ + compat?: PiAiCompatProfile } +/** + * Customization of one installed catalog model, keyed by its id in the + * route's `modelOverrides` dict — the same fields a `models` entry may set, + * with the id living in the key. Unlike a `models` list, overrides leave the + * rest of the catalog serving untouched, which is what makes "correct one + * model, keep the other thirty-seven" a three-line edit. + */ +export type PiAiModelOverride = Omit + /** The route-level facts model materialization reads. */ export interface RouteCatalogRequest { /** Provider route key, stamped onto every materialized model. */ @@ -98,6 +203,10 @@ export interface RouteCatalogRequest { baseURL?: string /** Configured catalog; absent means the whole installed catalog for this route. */ models?: readonly PiAiModelProfile[] + /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ + modelOverrides?: Readonly> + /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number /** Output capability for a model neither the entry nor the catalog sizes. */ @@ -123,6 +232,137 @@ function sharedCatalogApi(defaults: ReadonlyMap>): string | u return apis.size === 1 ? [...apis][0] : undefined } +/** The reasoning fields one materialized model carries. */ +interface ModelReasoning { + /** Whether the model reasons at all; `false` makes pi-ai ignore the map. */ + reasoning: boolean + /** The map dispatch reads; absent only when the installed entry's (or none) applies. */ + thinkingLevelMap?: ThinkingLevelMap +} + +/** + * Resolve one model's reasoning capability from its declared efforts. + * + * A declared dict translates to pi-ai's `thinkingLevelMap` with every level + * decided explicitly: declared levels carry their wire spelling, undeclared + * levels are pinned to `null` (unsupported). Pinning matters because pi-ai's + * own defaulting is asymmetric — an absent key means "supported" for the five + * base levels but "unsupported" for `xhigh`/`max` — and a profile author + * should not need to know that. A declared `off` with no value is the one + * exception: it stays absent from the map, which pi-ai reads as "supported, + * send nothing" — the correct dispatch where not thinking is the parameter's + * absence — while `off` with a value sends that value. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param base - the installed catalog entry of the same id, when one exists. + * @returns the reasoning fields the materialized model carries. + */ +function resolveModelReasoning( + provider: string, + entry: PiAiModelProfile, + base: Model | undefined, +): ModelReasoning { + const efforts = entry.reasoningEfforts + if (efforts === undefined) { + // Reasoning rides the installed entry or is absent: a bare capability flag + // would make pi-ai advertise effort levels with no `thinkingLevelMap` to + // spell them, and no listing endpoint reports a model's reasoning + // protocol. The entry's map (when any) arrives through the `...base` + // spread in the model literal. + return { reasoning: base?.reasoning ?? false } + } + // The installed entry's map may ride along through `...base`; pi-ai never + // reads it on a non-reasoning model, so stripping it is not worth a field + // enumeration here. + if (efforts === false) return { reasoning: false } + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union — outside the field's declared type, hence the widening — + // while an explicit `{}` arrives as an empty dict. Both declare nothing, + // and neither is a spelling of "inherit" or "disable". + if ((efforts as unknown) === null || Object.keys(efforts).length === 0) { + invalid(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set` + + ' false for a non-reasoning model, or omit the field to keep the installed catalog\'s capability') + } + const declared = THINKING_LEVELS.flatMap((level) => { + const wire = efforts[level] + return wire === undefined ? [] : [[level, wire] as const] + }) + for (const [level, wire] of declared) { + if (wire === null) { + if (level !== 'off') { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch` + + ' should send; only "off" may leave it empty') + } + } else if (wire.length === 0) { + invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`) + } + } + if (!declared.some(([level]) => level !== 'off')) { + invalid(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking` + + ' level, or set reasoningEfforts to false for a non-reasoning model') + } + const map: ThinkingLevelMap = {} + for (const level of THINKING_LEVELS) { + const wire = efforts[level] + if (wire === undefined) { + map[level] = null + } else if (wire !== null) { + map[level] = wire + } + } + return { reasoning: true, thinkingLevelMap: map } +} + +/** + * Resolve one model's compat block from the profile's reasoning switches. + * + * A model switch wins over the route switch; whatever neither sets keeps the + * installed entry's value, and a field no layer decides falls through to + * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes + * the switches at all: a model-level switch on any other protocol fails + * resolution, while a route-level default skips past such models — the same + * posture as the route-level `reasoning` default, which also must not fail + * models it does not fit. + * @param provider - provider route key, for diagnostics. + * @param entry - the configured model entry. + * @param route - the route-level switches, when any. + * @param base - the installed catalog entry of the same id, when one exists. + * @param api - the model's resolved wire protocol. + * @returns a `compat` field to spread into the model, or nothing. + */ +function resolveModelCompat( + provider: string, + entry: PiAiModelProfile, + route: PiAiCompatProfile | undefined, + base: Model | undefined, + api: string, +): { compat: OpenAICompletionsCompat } | Record { + const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat + const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort + if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} + if (api !== 'openai-completions') { + if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { + invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` + + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') + } + return {} + } + // The installed entry's compat matches the entry's OWN api — a route-level + // `api` repoint (an anthropic catalog served through an OpenAI-compatible + // gateway) leaves `base.compat` in the other protocol's shape, so it is + // inherited only while the resolved api still is the entry's. A repointed + // model starts from pi-ai's baseURL-derived detection instead, which is + // what a protocol change means for every other compat field too. + const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined + return { + compat: { + ...inherited, + ...thinkingFormat === undefined ? {} : { thinkingFormat }, + ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, + }, + } +} + /** One route's materialized catalog, plus the request caps its profile chose. */ export interface RouteCatalog { /** The materialized models in configuration order. */ @@ -156,14 +396,42 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { // schema materializes `[]` for the absent case, and an empty catalog could // serve no request anyway, so both mean "serve the installed catalog". const configured = request.models ?? [] + const overrides = request.modelOverrides ?? {} + // Every miss is refused, never skipped: an override that lands nowhere is a + // typo someone would otherwise hunt for in a silently unchanged model. + for (const [id, override] of Object.entries(overrides)) { + if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id') + if (defaults.size === 0) { + invalid(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route;` + + ' a declared route spells every model out in its models list') + } + if (configured.length > 0) { + invalid(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served` + + ' catalog, so declare the fields on its entries') + } + if (!defaults.has(id)) { + invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`) + } + // The id lives in the dict key; a value carrying its own would quietly + // rename the model it meant to customize. The static shape already omits + // it — this guards the schema boundary, which passes unknown keys through. + if ('id' in override) { + invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`) + } + } + // An override becomes the catalog entry's configuration, so everything a + // models entry may declare — capacities, efforts, compat — resolves through + // the same path with the same diagnostics and request-default semantics. const entries: readonly PiAiModelProfile[] = configured.length > 0 ? configured - : [...defaults.values()].map(model => ({ id: model.id })) + : [...defaults.values()].map(model => ({ id: model.id, ...overrides[model.id] })) if (entries.length === 0) { invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models' + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) + const routeCompatDefined = request.compat?.thinkingFormat !== undefined + || request.compat?.supportsReasoningEffort !== undefined const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -209,15 +477,17 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { api, provider, baseUrl, - // Reasoning rides the installed entry or is absent: a bare boolean would - // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell - // them, and no listing endpoint reports a model's reasoning protocol. - reasoning: base?.reasoning ?? false, input: base?.input ?? TEXT_ONLY, cost: base?.cost ?? NO_COST, contextWindow, maxTokens, + ...resolveModelReasoning(provider, entry, base), + ...resolveModelCompat(provider, entry, request.compat, base, api), } }) + if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { + invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' + + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 266dd94511..e52af4a3f4 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -21,8 +21,8 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { resolveRouteModels } from './catalog.ts' -import type { PiAiModelProfile } from './catalog.ts' +import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts' import { buildProvider, supportedProtocols } from './provider.ts' /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -34,7 +34,13 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ export const DEFAULT_MAX_TOKENS = 32_768 -export type { PiAiModelProfile } from './catalog.ts' +export type { + PiAiCompatProfile, + PiAiModelOverride, + PiAiModelProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, +} from './catalog.ts' /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */ export interface PiAiProviderProfile { @@ -56,6 +62,22 @@ export interface PiAiProviderProfile { * unset fields from the installed model of the same id. */ models?: PiAiModelProfile[] + /** + * Installed-catalog customizations by model id: each entry reshapes that + * one model with the same fields a {@link models} entry takes, while the + * rest of the catalog keeps serving untouched. Only meaningful on a catalog + * route with no `models` list — `models` already replaces the catalog, so + * an override beside it, on a route the catalog does not ship, or naming a + * model the catalog does not describe is refused rather than skipped. + */ + modelOverrides?: Record + /** + * Reasoning-dispatch switches for every `openai-completions` model on this + * route; each model's own `compat` overrides per field. What neither sets + * keeps the installed catalog entry's value, then pi-ai's baseURL-derived + * detection. + */ + compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor * the installed catalog sizes (default 262,144). A guess by construction, so @@ -133,23 +155,58 @@ const thinkingBudgets = z.object({ high: z.number(), }) -const modelProfile: z = z.object({ - id: z.string().required(), +const compatProfile: z = z.object({ + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsReasoningEffort: z.boolean(), +}) + +/** + * Keys are the offered levels, values their wire spellings. A valueless key + * (`off:`) survives validation because schemastery passes nullable data + * through before any member schema runs — `z.const(null)` only shapes the + * error for non-null wrong values and what a configuration surface renders. + * Only resolution decides which levels may leave the value empty, so the + * diagnostic can name the route and model. The assertion narrows + * schemastery's `Dict`, which types every literal key as required; dict + * validation is per-present-key, so the runtime shape is the partial record. + */ +const reasoningEfforts = z.dict( + z.union([z.string(), z.const(null)]), + z.union(THINKING_LEVELS), +) as unknown as z + +/** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */ +const modelFields = { name: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + // The union, not a bare dict: schemastery materializes an absent dict as + // `{}`, and absent must stay distinguishable — it means "inherit the + // installed catalog's capability", while `false` disables reasoning. + reasoningEfforts: z.union([z.const(false), reasoningEfforts]), + compat: compatProfile, +} + +const modelProfile: z = z.object({ + id: z.string().required(), + ...modelFields, }) +/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */ +const modelOverride: z = z.object(modelFields) + const profile = z.object({ apiKeyEnv: z.string().role('credential-ref'), displayName: z.string(), api: z.union(supportedProtocols()), baseURL: z.string(), models: z.array(modelProfile), + modelOverrides: z.dict(modelOverride), + compat: compatProfile, defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS), headers: z.dict(z.string()), - reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']), + reasoning: z.union(THINKING_LEVELS), thinkingBudgets, cacheRetention: z.union(['none', 'short', 'long']), transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']), @@ -240,6 +297,8 @@ export function resolveProfiles( ...source.api === undefined ? {} : { api: source.api }, ...source.baseURL === undefined ? {} : { baseURL: source.baseURL }, ...source.models === undefined ? {} : { models: source.models }, + ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides }, + ...source.compat === undefined ? {} : { compat: source.compat }, defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }) diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index e15e1e1c2e..c6eac9e263 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -32,11 +32,24 @@ * apiKeyEnv: ACME_GATEWAY_API_KEY * api: openai-completions * baseURL: https://gateway.acme.example/v1 + * # Reasoning dialect for a URL pi-ai cannot recognize. + * compat: + * thinkingFormat: deepseek * models: * - id: acme-large * name: Acme Large * contextWindow: 65536 * maxTokens: 4096 + * - id: acme-think + * name: Acme Think + * contextWindow: 262144 + * maxTokens: 32768 + * # key = selectable level, value = wire spelling; only off may + * # leave the value empty (supported, send nothing). + * reasoningEfforts: + * off: + * high: high + * max: ultra * ``` * * @module @deepseek-ai/dsh-llm-pi-ai @@ -56,7 +69,15 @@ import { discoverModels } from './discovery.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' export { Config } from './config.ts' -export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' +export type { + PiAiCompatProfile, + PiAiModelOverride, + PiAiModelProfile, + PiAiProviderProfile, + PiAiReasoningEfforts, + PiAiThinkingFormat, + ResolvedPiAiProviderProfile, +} from './config.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index aaca7f8bdf..d2e101505d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -409,6 +409,185 @@ describe('provider profile lifecycle', () => { .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } }) }) + it('serves declared reasoning efforts to selectors and honours the profile default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + reasoning: 'high', + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, low: 'low', high: 'high' }, + }], + }, + }, + }) + + // Declared levels reach the same seam catalog metadata does, so the + // effort picker works for a model pi-ai has never heard of. + await expect(ctx.llm.resolveModelInfo('acme-gateway', 'acme-think')).resolves.toMatchObject({ + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + }) + + it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'ultra' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + // The declared value, not the canonical level name, goes on the wire. + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'ultra' }) + + const undeclared = await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('max'), + messages: [], + }) + expect(undeclared.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) + expect(server.requests).toHaveLength(1) + }) + + it('dispatches the compat-switched dialect on a declared route', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + // Without the switch pi-ai guesses the dialect from the endpoint + // URL, and a private gateway's URL says nothing. + compat: { thinkingFormat: 'deepseek' }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + const prompt = (effort: string): Promise => assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId(effort), + messages: [], + }) + + await prompt('high') + expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, reasoning_effort: 'high' }) + + await prompt('off') + expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) + expect(server.requests[1]).not.toHaveProperty('reasoning_effort') + }) + + it('sends a declared off value as the effort parameter instead of omitting it', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: 'none', high: 'high' }, + }], + }, + }, + }) + + // The adapter strips a selected Off to "no reasoning option", and pi-ai's + // dispatch reads thinkingLevelMap.off exactly then — so the declared value + // still reaches the wire, which is the README's promise for `off: none`. + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('off'), + messages: [], + }) + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'none' }) + }) + + it('holds back reasoning_effort when the endpoint cannot take it', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + compat: { supportsReasoningEffort: false }, + models: [{ + id: 'acme-think', + contextWindow: 65_536, + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + expect(server.requests[0]).not.toHaveProperty('reasoning_effort') + }) + it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index b83c94be28..14cb10df76 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -10,8 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' -import { createModels } from '@earendil-works/pi-ai' -import type { Api, Model, Provider } from '@earendil-works/pi-ai' +import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' +import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai' import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' @@ -484,6 +484,248 @@ describe('catalog routes with per-model configuration', () => { }) }) +describe('per-model reasoning efforts', () => { + /** One hand-declared route holding exactly the given models. */ + function declared(models: LlmPiAi.PiAiModelProfile[]): Record { + return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } } + } + + /** The first materialized model of one route, or throw. */ + function modelOf(providers: Record, route = 'acme-gateway'): Model { + const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + if (model === undefined) throw new Error(`route "${route}" resolved no models`) + return model + } + + it('declares selectable levels with their wire spellings on a hand-declared model', () => { + const model = modelOf(declared([{ + id: 'acme-think', + reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' }, + }])) + + expect(model.reasoning).toBe(true) + // Undeclared levels are pinned null rather than left to pi-ai's own + // defaulting, which is asymmetric: an absent key means "supported" for the + // five base levels but "unsupported" for xhigh/max. A profile author + // should not need to know that. Declared `off` with no value stays absent + // from the map — supported, send nothing. + expect(model.thinkingLevelMap).toEqual({ + minimal: null, + medium: null, + xhigh: null, + low: 'low', + high: 'high', + max: 'ultra', + }) + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max']) + }) + + it('keeps a declared off value in the map for dispatch to send', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }])) + expect(model.thinkingLevelMap?.off).toBe('none') + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + }) + + it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => { + const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }])) + expect(getSupportedThinkingLevels(model)).toEqual(['high']) + }) + + it('narrows a catalog model’s levels in place', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(getSupportedThinkingLevels(catalogModel as Model)).toEqual(['off', 'high', 'max']) + + const model = modelOf({ + deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] }, + }, 'deepseek') + + expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high']) + // Only the reasoning fields change; identity and capacities stay catalog. + expect(model.name).toBe(catalogModel.name) + expect(model.contextWindow).toBe(catalogModel.contextWindow) + }) + + it('strips reasoning from a catalog model with false', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + expect(catalogModel.reasoning).toBe(true) + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek') + + expect(model.reasoning).toBe(false) + expect(getSupportedThinkingLevels(model)).toEqual(['off']) + }) + + it('inherits the catalog capability when the field is absent', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + + const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek') + + expect(model.reasoning).toBe(catalogModel.reasoning) + expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap) + }) + + it('rejects a declaration that offers nothing or spells a level it cannot send', () => { + const declare = (efforts: NonNullable): (() => unknown) => + () => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }])) + + expect(declare({})).toThrow(/empty reasoningEfforts/) + // A YAML `reasoningEfforts:` left valueless arrives as null through the + // schema union; it declares nothing and is not a spelling of "inherit". + expect(declare(null as never)).toThrow(/empty reasoningEfforts/) + expect(declare({ off: null })).toThrow(/offers no level beyond "off"/) + expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/) + expect(declare({ high: null })).toThrow(/only "off" may leave it empty/) + expect(declare({ high: '' })).toThrow(/must not be an empty string/) + }) +}) + +describe('modelOverrides', () => { + const deepseekModel = (): Model => { + const [model] = getBuiltinModels('deepseek') + if (model === undefined) throw new Error('the installed catalog ships no deepseek model') + return model + } + + it('reshapes one catalog model while the rest of the catalog keeps serving', () => { + const catalogSize = getBuiltinModels('deepseek').length + const target = deepseekModel() + const resolved = resolveProfiles({ + deepseek: { + modelOverrides: { + [target.id]: { + name: 'DeepSeek (proxied)', + maxTokens: 4096, + reasoningEfforts: { off: null, high: 'high' }, + }, + }, + }, + }) + const models = resolved.get('deepseek')?.piProvider.getModels() ?? [] + const reshaped = models.find(model => model.id === target.id) + if (reshaped === undefined) throw new Error('the overridden model vanished from the route') + + // The whole catalog still serves — that is the difference from `models`, + // which replaces it. + expect(models).toHaveLength(catalogSize) + expect(reshaped.name).toBe('DeepSeek (proxied)') + expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high']) + // An override's cap is explicit configuration, so it becomes the request + // default exactly as a models entry's would. + expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096) + // A sibling the overrides do not name is byte-identical to the catalog. + const sibling = models.find(model => model.id !== target.id) + expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens) + }) + + it('refuses every override that lands nowhere instead of skipping it', () => { + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } }, + })).toThrow(/which the installed catalog does not describe/) + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm' }], + modelOverrides: { m: { name: 'renamed' } }, + }, + })).toThrow(/a declared route spells every model out/) + const declaredOnly = deepseekModel() + expect(() => resolveProfiles({ + deepseek: { + models: [{ id: declaredOnly.id }], + modelOverrides: { [declaredOnly.id]: { name: 'renamed' } }, + }, + })).toThrow(/models already replaces the served catalog/) + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { '': { name: 'nameless' } } }, + })).toThrow(/empty model id/) + // The dict key is the id; a value smuggling its own would quietly rename + // the model it meant to customize. The schema passes unknown keys + // through, so resolution is the boundary that refuses it — the variable + // indirection mirrors that boundary by sidestepping the literal check. + const smuggled = { name: 'x', id: 'other' } + expect(() => resolveProfiles({ + deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } }, + })).toThrow(/sets "id", which is the dict key/) + }) +}) + +describe('reasoning-dispatch compat switches', () => { + /** The materialized models of one route, keyed by id. */ + function modelsOf(providers: Record, route: string): Map> { + const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] + return new Map(models.map(model => [model.id, model])) + } + + it('applies route switches to every openai-completions model, entries winning per field', () => { + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { thinkingFormat: 'deepseek' }, + models: [ + { id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } }, + { id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } }, + ], + }, + }, 'acme-gateway') + + expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' }) + expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false }) + }) + + it('merges the switches over the catalog entry’s own compat instead of replacing it', () => { + const [catalogModel] = getBuiltinModels('deepseek') + if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model') + const inherited = catalogModel.compat as OpenAICompletionsCompat + expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true) + + const models = modelsOf({ + deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] }, + }, 'deepseek') + + // The one switched field changes; the catalog's other quirks survive, + // because configuration has no way to restate them. + expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' }) + }) + + it('skips models of other protocols on a mixed route instead of failing them', () => { + // xai ships both completions and responses models, so a route-level switch + // must land on the former without invalidating the latter. + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + compat: { supportsReasoningEffort: false }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false) + expect(models.get(responses.id)?.compat).toEqual(responses.compat) + }) + + it('rejects a model-level switch on a protocol that has no such field', () => { + expect(() => resolveProfiles({ + anthropic: { + models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], + }, + })).toThrow(/exist only on openai-completions/) + }) + + it('rejects route switches no model on the route can take', () => { + expect(() => resolveProfiles({ + anthropic: { compat: { thinkingFormat: 'openai' } }, + })).toThrow(/no model on the route speaks openai-completions/) + }) +}) + describe('resolution snapshots', () => { it('finishes an in-flight request under the configuration it started with', async () => { const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/config.spec.ts b/packages/llm/llm-pi-ai/tests/config.spec.ts new file mode 100644 index 0000000000..607511c80b --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/config.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { Config } from '../src/config.ts' + +describe('reasoning schema boundary', () => { + const configWith = (model: Record): (() => unknown) => + () => Config({ + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'm', ...model }], + }, + }, + }) + + it('rejects a level pi-ai does not know at the write that produced it', () => { + expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/) + expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow() + }) + + it('keeps false distinguishable from an absent declaration', () => { + type Materialized = { providers: Record } + const withFalse = configWith({ reasoningEfforts: false })() as Materialized + expect(withFalse.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBe(false) + const absent = configWith({})() as Materialized + expect(absent.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBeUndefined() + }) + + it('rejects a thinking format outside the offered set', () => { + expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/) + }) +}) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 0ad49faa17..30660e1032 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.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 packages/llm/llm/README.md -README.md: d15b2c996d6d47371a3d6c5542eb5c253029dbae -README.zh.md: d965f15298f09ff9c2a953a69346c4e83136934b +README.md: ddbc2ea482ca0848fb0ee0813839cf5ff1829bcc +README.zh.md: 7a3b615d134e27d7c9892d6f411066d89938175b diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d15b2c996d..ddbc2ea482 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -97,5 +97,5 @@ Pass-through; the registry preserves the assembled request prefix, while the sel - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. -- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — the public home must be reachable before release. - **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index d965f15298..7a3b615d13 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -97,5 +97,5 @@ - **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**:没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见 [已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。 - **受产生方约束的变体在实际产生前不会加入**:`prefill`、每工具 `strict`、块 `cache` 提示与 `agent` 消息源变体因没有产生方而被剪除(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。 - **`BlockAssembler` 只处理核心块类型**:如果插件添加块类型的流从未由 `block-end` 关闭,`blocks()` 会抛出异常。 -- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:`FIXME`:创建公开 `deepseek-ai/deepseek-harness-sdk` 仓库是首次发布的前置条件。 +- **`APP_IDENTITY.url` 指向一个尚不存在的仓库**:该公开主页必须在首次发布前可访问。 - **`GenerateOptions.sessionId` 是本地声明的品牌类型**:导入 dsh-session 的 `SessionId` 会产生循环;未来拥有 id 的包可以消除该权宜之计。 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index cdaea4b96b..79cef011de 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this - // URL promises before the first release ships attribution pointing at it. + // TODO(public-home): Ensure this public source repository exists before release. url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', } diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index 2daa6d1a4c..848654ad3b 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -12,6 +12,8 @@ import type { ReasoningEffortId } from './brand.ts' /** Process-local identities of request objects assembled by dsh-agent-loop. */ const AGENT_LOOP_REQUESTS = new WeakSet() +// TODO(call-config-shape): Revisit which fields are epoch-level for cache reuse +// and where provider-specific request options belong. /** * Provider, model, reasoning effort, and sampling scalars of one conversation's * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; diff --git a/packages/sdk/telemetry/README.i18n.yaml b/packages/sdk/telemetry/README.i18n.yaml index 9baaa2ebff..1604b40bf4 100644 --- a/packages/sdk/telemetry/README.i18n.yaml +++ b/packages/sdk/telemetry/README.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 packages/sdk/telemetry/README.md -README.md: 1d33915f36e0af10eedac5f9ab34f2534268a327 -README.zh.md: af54cc2c78cb360d4305eeaf584c8330a0b7efa5 +README.md: c87735a93e7659f2913f4dd325176a8ae40cf29b +README.zh.md: 24d6e72d988f94cdbc6aa01607edbfc105213267 diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 1d33915f36..c87735a93e 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -14,7 +14,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. -The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. +The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its fail-safe `.invalid` placeholder must be replaced with the real endpoint before release. ## Model Experience diff --git a/packages/sdk/telemetry/README.zh.md b/packages/sdk/telemetry/README.zh.md index af54cc2c78..24d6e72d98 100644 --- a/packages/sdk/telemetry/README.zh.md +++ b/packages/sdk/telemetry/README.zh.md @@ -14,7 +14,7 @@ Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 -收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将其 `.invalid` 占位值替换为真实端点。 +收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`);发布前必须将作为安全兜底的 `.invalid` 占位值替换为真实端点。 ## 模型体验 diff --git a/packages/sdk/telemetry/src/reporter.ts b/packages/sdk/telemetry/src/reporter.ts index d41c1db9b7..b7ac75a23f 100644 --- a/packages/sdk/telemetry/src/reporter.ts +++ b/packages/sdk/telemetry/src/reporter.ts @@ -16,12 +16,11 @@ import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts' import { SecretRedactor } from './secret-redactor.ts' /** - * Placeholder collection endpoint. This is a fixed protocol constant, not a - * deployment tunable. - * - * FIXME(ccyu): replace with the real telemetry endpoint before release. The - * `.invalid` TLD guarantees delivery fails harmlessly until then. + * Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees + * delivery fails harmlessly until a collector is deployed. This is a fixed + * protocol constant, not a deployment tunable. */ +// TODO(telemetry-endpoint): Replace the placeholder before release. export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk' /** Wire-envelope schema version; bump on any incompatible body change. */ diff --git a/packages/skill/skill/README.i18n.yaml b/packages/skill/skill/README.i18n.yaml index 03d1b13fe8..2ca9cbac01 100644 --- a/packages/skill/skill/README.i18n.yaml +++ b/packages/skill/skill/README.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 packages/skill/skill/README.md -README.md: f538ae668ccff291be86348627d5547150f460df -README.zh.md: d61a242d01df1e22270c1cb049b922536654bbd6 +README.md: 3dc2bcfa5775736717bdebcb92329d5655198234 +README.zh.md: d11f90d5a8356f06df63aa249a1f8b5851f36f5f diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index f538ae668c..3dc2bcfa57 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -37,6 +37,10 @@ This package owns the `ctx.skills` interface. It does not know whether skills co | `{ modelInvocable: false, userInvocable: true }` | excluded | included | | `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | +### Shared model-facing rendering + +`renderSkillContent(skill)` renders one loaded skill as the canonical `` block (escaped `name` attribute, resource hints, verbatim body). It is the single truth for both loading paths: `dsh-tool-skill` returns it as the `skill` tool result and injects it at the user-explicit gesture boundary, so the model sees one shape regardless of who initiated the load. `escapeText` is exported beside it for consumers embedding prose in the same markup frame. The package also declares the `skill-invocation` `MessageSource` kind ({ name, form: 'instructions' }) that user-explicit injection stamps on its messages — transcript consumers present the invocation from this metadata instead of re-parsing the body. + `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains the trusted, policy-neutral loading primitive, so every user- or model-facing consumer must enforce the predicate that matches its surface before exposing or loading a skill. ## Provider Contract diff --git a/packages/skill/skill/README.zh.md b/packages/skill/skill/README.zh.md index d61a242d01..d11f90d5a8 100644 --- a/packages/skill/skill/README.zh.md +++ b/packages/skill/skill/README.zh.md @@ -37,6 +37,10 @@ | `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | | `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | +### 共享的面向模型渲染 + +`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind({ name, form: 'instructions' }),用户显式注入会把它打在自己的消息上——transcript(文本记录)消费方依据这份元数据呈现该次调用,而不是重新解析正文。 + `isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。 ## 提供方契约 diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 73469b89a7..f77f56f6d1 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -26,6 +26,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -33,6 +34,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 32f7112542..42478279b4 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,6 +10,7 @@ */ import { Context, Service } from 'cordis' +import { assertNever } from '@deepseek-ai/dsh-llm' import z from 'schemastery' import type Schema from 'schemastery' @@ -119,6 +120,97 @@ export function isUserInvocable(skill: Pick): boolea return skill.invocation.userInvocable } +/** + * Durable source for the context message a user-explicit skill invocation + * injects: the user's own words ride a plain user message, and the rendered + * skill body follows as injected `instructions`-form context carrying this + * source, so transcript consumers present the injection from metadata + * instead of re-parsing the model-facing text. + */ +export interface SkillInvocationSource { + readonly kind: 'skill-invocation' + /** Invoked skill name, validated user-invocable at the injecting boundary. */ + readonly name: string + /** Injected skill bodies are instructions for the model to follow. */ + readonly form: 'instructions' +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** A user-explicit skill invocation injected by the host. */ + 'skill-invocation': SkillInvocationSource + } +} + +/** + * Render one loaded skill for the model. The output is shared verbatim by the + * `skill` tool result and the user-explicit invocation injection, so the model + * sees one canonical `` shape on both paths. The name rides an + * escaped attribute; the body is embedded verbatim (skills are trusted local + * content, and user-supplied invocation text stays outside this wrapper). + * @param skill - name, provider, optional resource base, and body to render. + * @returns the complete model-facing `` block. + */ +export function renderSkillContent(skill: Pick): string { + const resourceHint = renderResourceHint(skill) + return [ + ``, + '', + ...resourceHint, + '', + '', + '', + skill.content, + '', + '', + ].join('\n') +} + +function renderResourceHint(skill: Pick): string[] { + const base = skill.resourceBase + if (base === undefined) { + return [ + `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, + 'Load referenced resources only as needed.', + ] + } + switch (base.kind) { + case 'directory': + return [ + `Base directory for this skill: ${escapeText(base.path)}`, + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + ] + case 'url': + return [ + `Base URL for this skill: ${escapeText(base.url)}`, + 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', + ] + case 'opaque': + return [ + `Resources for this skill: ${escapeText(base.description)}`, + 'Load referenced resources only as needed.', + ] + /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ + default: + return assertNever(base, 'SkillResourceBase.kind') + /* v8 ignore stop */ + } +} + +function escapeAttr(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') +} + +/** + * Escape model-facing prose embedded inside skill markup so provider-supplied + * text cannot open or close framing tags. + * @param value - raw prose to embed. + * @returns the escaped text. + */ +export function escapeText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +} + /** One catalog observation plus whether discovery completed within a stable catalog revision. */ export interface SkillCatalogSnapshot { /** Sorted invocation-neutral summaries collected in this observation. */ diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index 39ef384f3c..d48263cfe0 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import SkillService, { isModelInvocable, isUserInvocable, + renderSkillContent, type SkillCandidate, type SkillDefinition, type SkillInvocationPolicy, @@ -1013,3 +1014,65 @@ describe('SkillService registry', () => { expect(await ctx.skills.get('same-skill')).toBeUndefined() }) }) + +describe('renderSkillContent', () => { + it('renders a directory-based skill with the shared wrapper', () => { + const text = renderSkillContent({ + name: 'demo-skill', + provider: 'memory', + resourceBase: { kind: 'directory', path: '/tmp/demo' }, + content: 'Do the thing.', + }) + expect(text).toBe([ + '', + '', + 'Base directory for this skill: /tmp/demo', + 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', + '', + '', + '', + 'Do the thing.', + '', + '', + ].join('\n')) + }) + + it('renders url and opaque resource hints', () => { + const url = renderSkillContent({ + name: 'url-skill', + provider: 'memory', + resourceBase: { kind: 'url', url: 'https://example.test/base/' }, + content: 'Body.', + }) + expect(url).toContain('Base URL for this skill: https://example.test/base/') + expect(url).toContain('Resolve relative URLs mentioned by this skill against the base URL before using them.') + + const opaque = renderSkillContent({ + name: 'opaque-skill', + provider: 'memory', + resourceBase: { kind: 'opaque', description: 'archive ' }, + content: 'Body.', + }) + expect(opaque).toContain('Resources for this skill: archive <bundle>') + }) + + it('falls back to the provider hint without a resource base', () => { + const text = renderSkillContent({ + name: 'provider-skill', + provider: 'remote ', + content: 'Body.', + }) + expect(text).toContain('Resources for this skill are managed by provider "remote <hub>".') + }) + + it('escapes hostile attribute names and keeps the body verbatim', () => { + const text = renderSkillContent({ + name: 'x"& and as-is.', + }) + expect(text).toContain('') + expect(text).toContain('Keep and as-is.') + }) +}) diff --git a/packages/skill/skill/tsconfig.json b/packages/skill/skill/tsconfig.json index e882ed2d72..82e62d7c91 100644 --- a/packages/skill/skill/tsconfig.json +++ b/packages/skill/skill/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../llm/llm" + }, { "path": "../../support/invariants" } diff --git a/packages/skill/tool-skill/README.i18n.yaml b/packages/skill/tool-skill/README.i18n.yaml index b57689d742..b9aa148fd1 100644 --- a/packages/skill/tool-skill/README.i18n.yaml +++ b/packages/skill/tool-skill/README.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 packages/skill/tool-skill/README.md -README.md: 8e0bff5d1c4853092d412b8f7f9528d4b00d9626 -README.zh.md: c6b815bef59eb1f14be0892078694f129366d004 +README.md: b7309657d85a3d2a19de78a4ee6173d742519daa +README.zh.md: f430f4027c917c5c9b97a56d1a7d7a617670b25c diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 8e0bff5d1c..b7309657d8 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -36,7 +36,7 @@ Tool execution does not add a synthetic context message. Its freshly loaded resu #### What the model sees -If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below as a durable user-role message before the first request, with one data-dependent entry per sorted skill. Later membership, description, or visibility changes append a complete replacement using the same `` envelope; deleting every skill appends an empty envelope with an explicit instruction not to use older names. The template's closing sentence is the seam rule against double-loading: the user-explicit gesture boundary (the pre-step listener below) injects the same `renderSkillContent` output (shared from `@deepseek-ai/dsh-skill`) inline, and the catalog tells the model to follow that block instead of re-loading the skill through the tool; the replacement-catalog template carries the same sentence in both arms, including the emptied catalog. ##### Skill catalog template @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` @@ -144,6 +145,20 @@ Only a failing call adds these retained tokens. Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### User-explicit invocation injection + +#### What the model sees + +A whitespace-bounded `/name` token anywhere in a claimed user message, naming a user-invocable skill in the workspace catalog, injects that skill's full `` rendering (the exact result-template shape above) as a `user`-role instructions context appended after every other injection of that step — background first, the material to act on last. Only direct user input is scanned, the check runs on the loaded definition, and unknown or user-disabled names stay ordinary prose. This is the sole entry point for `disable-model-invocation` skills, which the catalog and the `skill` tool never expose; the catalog's closing sentence tells the model to follow the injected block instead of re-loading it. + +#### Token effect + +Each gesture adds one rendered skill body to that turn as injected context — the same size as the tool result for the same skill, paid deterministically at the user's request instead of at the model's discretion. Repeated gestures for one skill within one step inject once. + +#### KV Cache effect + +Append-only; the injection lands after the reusable request prefix inside the step's message batch and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. diff --git a/packages/skill/tool-skill/README.zh.md b/packages/skill/tool-skill/README.zh.md index c6b815bef5..f430f4027c 100644 --- a/packages/skill/tool-skill/README.zh.md +++ b/packages/skill/tool-skill/README.zh.md @@ -36,7 +36,7 @@ #### 模型看到的内容 -如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。 +如果存在模型可调用 skill,且可见的正是这个 `skill` 工具,agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。模板的结尾一句是防止双重加载的 seam 规则:用户显式的手势边界(下文的 pre-step 监听器)会把同一份 `renderSkillContent` 输出(共享自 `@deepseek-ai/dsh-skill`)内联注入,目录则告诉模型遵循该块,而不是再经工具重新加载该 skill;替换目录模板的两个臂——包括清空后的目录——都携带同一句话。 ##### Skill 目录模板 @@ -49,6 +49,7 @@ A skill is a reusable set of task-specific instructions. The following skills ar If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. +A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill. ``` @@ -144,6 +145,20 @@ Load referenced resources only as needed. 仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 用户显式调用注入 + +#### 模型看到的内容 + +已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token,会把该 skill 的完整 `` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill;目录的结尾一句会告诉模型遵循注入块,而不是重新加载它。 + +#### Token 影响 + +每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。 + +#### KV Cache 影响 + +仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。 + ## 已知限制与暂缓事项 - **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。 diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ddc45d18e9..604cd63bcf 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -9,12 +9,15 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' -import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' import { + escapeText, isModelInvocable, isSkillName, - type SkillDefinition, + isUserInvocable, + renderSkillContent, + type SkillInvocationSource, type SkillSummary, } from '@deepseek-ai/dsh-skill' @@ -160,6 +163,49 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } + // User-explicit skill invocation: a claimed user message whose first line + // starts with `/` naming a user-invocable skill is a deterministic + // load gesture. The rendered body enters this step as injected + // instructions context appended after every other injection — background + // first (workspace rules, runtime policy, the catalog), the material the + // model must act on last, closest to its answer. Registration order makes + // that placement deterministic: this listener registers before the catalog + // listener, so the waterfall hands it the catalog-bearing list to extend. + // Only `source.kind === 'user'` messages are scanned — external text + // cannot forge the gesture — and a token naming no user-invocable skill + // stays ordinary prose (the command registry is a different closed + // namespace, resolved client-side before a line ever becomes a prompt). + // This is the only entry point for `disable-model-invocation` skills; the + // catalog and the `skill` tool below never see them. + ctx.on('agent/pre-step', async ( + { agent, messages, signal }, + next, + ): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + const names = invokedSkillNames(messages) + if (names.length === 0) return decision + signal.throwIfAborted() + const lookup = { cwd: agent.session.header.cwd, signal } + const injections: UserMessage[] = [] + for (const name of names) { + const skill = await ctx.skills.get(name, lookup) + signal.throwIfAborted() + // Unknown names and user-disabled skills stay plain prose: the + // gesture was never a claim this boundary recognizes. The check sits + // on the loaded definition — the single lookup that produces what is + // actually injected. + if (skill === undefined || !isUserInvocable(skill)) continue + const source: SkillInvocationSource = { kind: 'skill-invocation', name, form: 'instructions' } + injections.push(createUserMessage({ + content: [{ type: 'text', text: renderSkillContent(skill) }], + source, + })) + } + if (injections.length === 0) return decision + return { kind: 'enter', messages: [...decision.messages, ...injections] } + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/pre-step', async ( @@ -203,52 +249,6 @@ export function apply(ctx: Context, config: Config = {}): void { }) } -function renderSkillContent(skill: Pick): string { - const resourceHint = renderResourceHint(skill) - return [ - ``, - '', - ...resourceHint, - '', - '', - '', - skill.content, - '', - '', - ].join('\n') -} - -function renderResourceHint(skill: Pick): string[] { - const base = skill.resourceBase - if (base === undefined) { - return [ - `Resources for this skill are managed by provider "${escapeText(skill.provider)}".`, - 'Load referenced resources only as needed.', - ] - } - switch (base.kind) { - case 'directory': - return [ - `Base directory for this skill: ${escapeText(base.path)}`, - 'Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.', - ] - case 'url': - return [ - `Base URL for this skill: ${escapeText(base.url)}`, - 'Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed.', - ] - case 'opaque': - return [ - `Resources for this skill: ${escapeText(base.description)}`, - 'Load referenced resources only as needed.', - ] - /* v8 ignore start -- SkillResourceBase is a closed union; a future kind must fail compilation here. */ - default: - return assertNever(base, 'SkillResourceBase.kind') - /* v8 ignore stop */ - } -} - function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessage { return createUserMessage({ content: [{ @@ -262,6 +262,7 @@ function renderCatalogMessage(entries: SkillCatalogSource['entries']): UserMessa '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], @@ -277,9 +278,11 @@ function renderCatalogUpdate(entries: SkillCatalogSource['entries']): UserMessag const availability = entries.length === 0 ? [ 'No skills are currently available through the `skill` tool. Do not use names from earlier skill catalogs.', + 'A user may still invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool for it.', ] : [ 'Use only names in this replacement catalog. If the user names a listed skill, or the task clearly matches its description, call the `skill` tool with the exact name before acting.', + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', ] return createUserMessage({ content: [{ @@ -394,10 +397,33 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void { } } -function escapeAttr(value: string): string { - return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') -} +/** + * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere + * in the text — the same word-boundary shape the transcript chip decoration + * uses, so a gesture reads as one wherever it sits in the sentence. A second + * `/` or any non-boundary character breaks the match, which keeps file paths + * (`/usr/bin`) and fractions (`5/8`) out. + */ +const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g -function escapeText(value: string): string { - return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') +/** + * `/name` gesture tokens from the claimed user messages, deduplicated in + * first-seen order. Every text block of direct user input is scanned; no + * other source can forge a gesture. + * @param messages - the step's claimed batch. + * @returns candidate skill names, unvalidated against the registry. + */ +function invokedSkillNames(messages: readonly UserMessage[]): string[] { + const names: string[] = [] + for (const message of messages) { + if ((message.source as { kind?: unknown }).kind !== 'user') continue + for (const block of message.content) { + if (block.type !== 'text') continue + for (const match of block.text.matchAll(SKILL_GESTURE)) { + const name = match[2] + if (name !== undefined && !names.includes(name)) names.push(name) + } + } + } + return names } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 0755e398a0..5e0fe58855 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => { '', '', "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.", + 'A user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.', '', ].join('\n'), }], @@ -914,3 +915,134 @@ describe('dsh-tool-skill', () => { expect(vanishedBlock.text).toContain('skill "vanishing-skill" is unknown or no longer available') }) }) + +describe('user-explicit invocation injection', () => { + async function writePolicySkill(root: string, name: string, description: string, policy: string, body: string): Promise { + const dir = join(root, name) + await mkdir(dir, { recursive: true }) + const policyLines = policy === '' ? '' : `${policy}\n` + await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n${policyLines}---\n\n${body}\n`) + } + + function gesture(text: string): UserMessage { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + } + + async function invokeHarness(): Promise<{ ctx: Context; agent: Agent }> { + const home = await tempDir('invoke') + const skillsRoot = join(home, '.agents', 'skills') + await writePolicySkill(skillsRoot, 'hidden-demo', 'User-only demo', 'disable-model-invocation: true', 'Say the magic word: PINEAPPLE.') + await writePolicySkill(skillsRoot, 'shared-skill', 'Ordinary skill', '', 'Shared instructions.') + await writePolicySkill(skillsRoot, 'model-only-skill', 'Model only', 'user-invocable: false', 'Model-only instructions.') + const ctx = await setup(home) + return { ctx, agent: agentForCwd(home) } + } + + it('injects a user-invocable skill named by a leading /token, after every other injection', async () => { + const { ctx, agent } = await invokeHarness() + const first = gesture('/hidden-demo what does this do') + const second = gesture('plain follow-up prose') + const decision = await proposeStep(ctx, agent, [first, second]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const kinds = decision.messages.map(message => (message.source as { kind: string }).kind) + // Background injections (the catalog here) sit between the claimed batch + // and the invoked body: the material the model must act on comes last. + expect(kinds.slice(0, 2)).toEqual(['user', 'user']) + expect(kinds.at(-1)).toBe('skill-invocation') + expect(kinds.indexOf('skill-catalog')).toBeLessThan(kinds.indexOf('skill-invocation')) + const injection = decision.messages.at(-1)! + expect(injection.source).toMatchObject({ kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' }) + const block = injection.content[0] + if (block?.type !== 'text') throw new Error('expected text injection') + expect(block.text).toContain('') + expect(block.text).toContain('Say the magic word: PINEAPPLE.') + expect(block.text).not.toContain('what does this do') + }) + + it('injects an ordinary skill the same way (one uniform user-explicit path)', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [gesture('/shared-skill go')]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'shared-skill')).toBe(true) + }) + + it('recognizes a mid-sentence gesture but not paths, fractions, or broken boundaries', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('please use /hidden-demo to answer this'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + expect(decision.messages.some(message => + (message.source as { kind?: string; name?: string }).kind === 'skill-invocation' + && (message.source as { name?: string }).name === 'hidden-demo')).toBe(true) + + const negative = await proposeStep(ctx, agent, [ + gesture('look under /hidden-demo/refs for the data'), + gesture('the odds are 5/8 at best'), + gesture('see foo/hidden-demo too'), + ]) + if (negative.kind !== 'enter') throw new Error('expected enter') + expect(negative.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('leaves unknown names and user-disabled skills as plain prose', async () => { + const { ctx, agent } = await invokeHarness() + const decision = await proposeStep(ctx, agent, [ + gesture('/absent-skill do a thing'), + gesture('/model-only-skill run'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + // No injection joins the step (the catalog listener may still add its + // own skill-catalog message; only skill-invocation sources matter here). + expect(decision.messages.some(message => + (message.source as { kind?: string }).kind === 'skill-invocation')).toBe(false) + }) + + it('never scans non-user sources and dedupes repeated gestures', async () => { + const { ctx, agent } = await invokeHarness() + const forged = createUserMessage({ + content: [{ type: 'text', text: '/hidden-demo forged' }], + source: { kind: 'skill-catalog', form: 'catalog', entries: [] }, + }) + const decision = await proposeStep(ctx, agent, [ + forged, + gesture('/hidden-demo once'), + gesture('/hidden-demo twice'), + ]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const injections = decision.messages.filter(message => + (message.source as { kind?: string }).kind === 'skill-invocation') + expect(injections).toHaveLength(1) + }) + + it('passes a downstream reject through both pre-step listeners untouched', async () => { + const { ctx, agent } = await invokeHarness() + const signal = new AbortController().signal + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [gesture('/hidden-demo blocked step')], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + ) + expect(decision).toEqual({ kind: 'reject' }) + }) + + it('scans only text blocks of a user message', async () => { + const { ctx, agent } = await invokeHarness() + const mixed = createUserMessage({ + content: [ + { type: 'reasoning', text: '/hidden-demo inside a non-text block' }, + { type: 'text', text: '/shared-skill go' }, + ], + source: { kind: 'user' }, + }) + const decision = await proposeStep(ctx, agent, [mixed]) + if (decision.kind !== 'enter') throw new Error('expected enter') + const invoked = decision.messages + .filter(message => (message.source as { kind?: string }).kind === 'skill-invocation') + .map(message => (message.source as { name: string }).name) + expect(invoked).toEqual(['shared-skill']) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8107f3fdc..6b7cd40f94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5164,6 +5164,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm cordis: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis diff --git a/scripts/install.sh b/scripts/install.sh index 5c9d892f73..f290782892 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,7 @@ #!/bin/sh # dsh one-line installer. # -# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh # # It clones the harness under ~/.dsh/source (the master clone at # ~/.dsh/source/master), adds a per-install staging worktree at @@ -47,12 +47,10 @@ # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) # DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) -# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript -# entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu DSH_REF=${DSH_REF:-master} -DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git} +DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git} # DSH_SOURCE is the staging-worktree container and the default home of `current`. # DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, # while adoption discovers an existing clone anywhere on disk. Remember whether diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 6770381526..89e417558c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-harness/deepseek-harness/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 02a64b023a..e3397237a3 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-harness/deepseek-harness' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -203,7 +203,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 84eeeb10bb..6979fb894d 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -77,6 +77,12 @@ describe('gate graph validation', () => { await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) + it('keeps the public repository link policy in the documentation gate', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id)) + + expect(ids).toContain('public-repository-links') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 03064cb242..8e6f336e96 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -572,6 +572,7 @@ function docSyncLeafGates(options: { pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }), pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }), pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }), + pnpmScript('public-repository-links', 'verify-public-repository-links', { label: 'public repository links' }), pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }), pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }), pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }), diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts new file mode 100644 index 0000000000..ec12f38427 --- /dev/null +++ b/scripts/verify-public-repository-links.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' + +describe('public repository link policy', () => { + it('rejects encoded and case-varied internal identities without blocking public repositories', () => { + const internalOwner = ['deepseek', 'harness'].join('-') + const internalRepository = [internalOwner, internalOwner].join('/') + const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = internalRepository.replace('/', '/') + const jsonEscapedRepository = internalRepository.replace('/', '\\/') + const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) + const source = [ + 'https://github.com/deepseek-ai/deepseek-harness-sdk', + `https://github.com/${internalOwner}/cordis`, + `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + `https://github.com/${encodedRepository}/issues/2`, + `https://github.com/${htmlEncodedRepository}/issues/3`, + `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, + `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, + `${internalOwner.toUpperCase()}#6`, + ].join('\n') + + expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 3 }, + { file: 'subject.md', line: 4 }, + { file: 'subject.md', line: 5 }, + { file: 'subject.md', line: 6 }, + { file: 'subject.md', line: 7 }, + { file: 'subject.md', line: 8 }, + ]) + }) +}) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts new file mode 100644 index 0000000000..6d1e537733 --- /dev/null +++ b/scripts/verify-public-repository-links.ts @@ -0,0 +1,90 @@ +/** Reject tracked files that expose the internal repository identity. */ + +import { execFileSync } from 'node:child_process' +import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const root = resolve(import.meta.dirname, '..') +const internalOwner = ['deepseek', 'harness'].join('-') +const internalRepository = [internalOwner, internalOwner].join('/') +const internalIssueShorthand = `${internalOwner}#` + +const namedReferenceCharacters: Readonly> = { + hyphen: '-', + num: '#', + sol: '/', +} + +/** Normalize source spellings that render or decode to repository separators. */ +function canonicalReferenceText(source: string): string { + return source + .replaceAll('\\/', '/') + .replace(/\\u(0023|002d|002f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/%(23|2d|2f)/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16))) + .replace(/&#(?:(\d+)|x([\da-f]+));/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + const code = Number.parseInt(decimal ?? hexadecimal ?? '', decimal === undefined ? 16 : 10) + return code === 35 || code === 45 || code === 47 ? String.fromCodePoint(code) : entity + }) + .replace(/&(hyphen|num|sol);/gi, (entity, name: string) => namedReferenceCharacters[name.toLowerCase()] ?? entity) + .normalize('NFKC') + .toLowerCase() +} + +/** One tracked reference to the internal repository. */ +export interface InternalRepositoryReference { + /** Repository-relative file path. */ + file: string + /** One-based source line. */ + line: number +} + +/** + * Locate internal-repository references in one text file. + * @param file - Repository-relative path used in diagnostics. + * @param source - Text to inspect. + * @returns every matching source line. + */ +export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const [index, line] of source.split('\n').entries()) { + const canonicalLine = canonicalReferenceText(line) + if (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand)) { + references.push({ file, line: index + 1 }) + } + } + return references +} + +function trackedFiles(repoRoot: string): string[] { + return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' }) + .split('\0') + .filter(file => file !== '') +} + +function scanRepository(repoRoot: string): InternalRepositoryReference[] { + const references: InternalRepositoryReference[] = [] + for (const file of trackedFiles(repoRoot)) { + const path = resolve(repoRoot, file) + if (!existsSync(path)) continue + const stat = lstatSync(path) + if (!stat.isFile() && !stat.isSymbolicLink()) continue + const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') + if (source.includes('\0')) continue + references.push(...findInternalRepositoryReferences(file, source)) + } + return references +} + +const invokedPath = process.argv[1] +const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href +if (isMain) { + const references = scanRepository(root) + if (references.length === 0) { + console.log('verify-public-repository-links: tracked files expose no internal repository identity.') + } else { + console.error('verify-public-repository-links: internal repository references found:') + for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) + process.exitCode = 1 + } +} diff --git a/tsconfig.host.json b/tsconfig.host.json index 7e7cd982fc..1467555ed1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -10,6 +10,7 @@ "include": [ "apps/web/tests/scaffold.ts", "apps/web/tests/default-model.e2e.ts", + "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/core-web-profile.snapshot.ts", @@ -44,6 +45,7 @@ "apps/web/tests/markdown-inline-code-links.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", + "apps/web/tests/skill-user-invoke.e2e.ts", "apps/web/tests/permission-policy-context.e2e.ts", "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", diff --git a/vendor/README.md b/vendor/README.md index 9fa97413c2..4bf1d43f46 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,6 +43,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. 12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 26b9305c52..5eece997c9 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -170,7 +170,7 @@ export class Include extends EntryTree { private readonly: boolean private content?: string private data?: EntryOptions[] - private writeTask?: NodeJS.Timeout + private writeTask?: NodeJS.Timeout | undefined private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index da3892eaa2..4dc1a5774b 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { 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}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ 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}` + return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` }, text: 'Edit this page on GitHub', },