Merge branch 'master' into worktree/merge-compact-card

This commit is contained in:
Yichen Jiang
2026-08-08 14:45:55 +08:00
committed by GitHub
192 changed files with 2422 additions and 404 deletions

View File

@@ -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 },
],
})
},

View File

@@ -163,6 +163,7 @@ export class FakeApiClient implements IApiClient {
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => 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)),

View File

@@ -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:

View File

@@ -57,10 +57,11 @@ function materializeNode(
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): 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,

View File

@@ -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<StoreDecl, StoreFactory>
/** 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<EngineStoreHandle['create']>
/** Store axis record: one per live handle, dropped when the last holding entry unloads. */
interface StoreAxisRecord {

View File

@@ -198,6 +198,7 @@ export class FakeApiClient implements IApiClient {
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => 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)),

View File

@@ -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: '<skill_content name="hidden-demo">body</skill_content>' }],
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

View File

@@ -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 `<skill>name</skill>` 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>([^<]+)<\/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(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
parts.push(
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
{label}
</span>,
)
cursor = legacy ? m.index + m[0].length : tokenStart + label.length
cursor = tokenStart + label.length
}
if (parts.length === 0) return <MessageText text={text} />
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)

View File

@@ -871,6 +871,7 @@ describe('MessageItem arms', () => {
view.rerender(<MessageItem t={t} node={node} retryActive />)
expect(view.getByRole('status').textContent).toBe('正在重试模型请求1/2 · 1s')
})
})
describe('formatMessageClock', () => {

View File

@@ -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'),

View File

@@ -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

View File

@@ -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 `<skill>name</skill>` 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 `<skill>name</skill>` 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 `<skill_content>` 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 `<skill>` 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 `<skill_content>` 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.

View File

@@ -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-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤pick 一个候选会把字面文本 `/name ` 经 slash 流水线落进草稿(决策 21 的纯文本引用source 的 `codec` 拥有该引用的两种投影:`clipboardText``/name``serialize` → 提交时生成的模型形式 `<skill>name</skill>`。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-flightscope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本(决策 21——本 source 不实现任何裁决钩子,也没有引用 codec旧的 `<skill>name</skill>` 形式已随移除裁定消失。确定性在宿主侧pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token并为每一种前端注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令裁决在客户端把该行认领走它根本不会成为提示词——这是有意的优先级与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
`skill.list` 失败时 `candidates` 抛出异常slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pendingready 状态。
@@ -14,23 +16,22 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
## 模型体验
### 用户提示词中的 skill 引用文本
### 用户显式 skill 调用
#### 模型看到的内容
被 pick 的候选会把字面文本 `/name ` 落进草稿(决策 21纯文本`<skill>` 标签);该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧展开。与实际 skill 的关联在模型侧建立且具有非确定性:会话前缀已携带 skill 目录(由 `dsh-tool-skill` 渲染),引用名称与目录条目匹配,正是这一点引导模型去加载它
用户消息原样到达模型,字面文本 `/name` 也包含在内。随后宿主的 pre-step 边界(`dsh-tool-skill`)把规范的 `<skill_content>` 块——与 `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 候选;下一次击键会重新轮询已落定的缓存。

View File

@@ -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 `<skill>` 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 `<skill>` 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(`<skill>${ref}</skill>`),
},
}
const slash = ctx.get('slash') as SlashServiceContract
ctx.on('connection/reset', clearAll)

View File

@@ -9,6 +9,7 @@ export const zh = {
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
'menu.userOnly': '仅用户',
} satisfies Record<string, string>
/** 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<SkillKey, string>

View File

@@ -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 <skill>name</skill>', 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('<skill>deploy</skill>')
})
})
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' },
])
})
})

View File

@@ -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

View File

@@ -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.

View File

@@ -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 预算。

View File

@@ -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

View File

@@ -527,6 +527,7 @@ describe('dsh-agent-spine-demo bundle', () => {
</available_skills>
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 <skill_content> block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill.
</system-reminder>",
"type": "user/message",
},

View File

@@ -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)

View File

@@ -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

View File

@@ -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 `<skill_content>` 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.

View File

@@ -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并以注入的 `<skill_content>` 上下文作答因此每一种前端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` 错误应答这些领域。

View File

@@ -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<T>(
request: RpcRequest<unknown>, sessionId: SessionId,
): Promise<{ agent: Agent } | { refused: RpcResponse<T> }> {
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) {

View File

@@ -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<Wire<SkillEntry>>
/** skill.list request payload. */

View File

@@ -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 `<skill>name</skill>` 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<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -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()
})

View File

@@ -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 () => {

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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.<provider>`), 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.

View File

@@ -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.<provider>`)中声明每个已安装 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` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。

View File

@@ -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<Api>['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<ModelThinkingLevel, true> = {
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<OpenAICompletionsCompat['thinkingFormat']>
/**
* 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<PiThinkingFormat, WithheldThinkingFormat>
/**
* 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<PiAiThinkingFormat, true> = {
'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<string, Provider> | undefined
/**
@@ -71,6 +131,32 @@ export function catalogModels(provider: string): Map<string, Model<Api>> {
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<Record<ModelThinkingLevel, string | null>>
/**
* 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<PiAiModelProfile, 'id'>
/** 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<Record<string, PiAiModelOverride>>
/** 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, Model<Api>>): 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<Api> | 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<Api> | undefined,
api: string,
): { compat: OpenAICompletionsCompat } | Record<string, never> {
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<string>()
const configuredMaxTokens = new Map<string, number>()
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 }
}

View File

@@ -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<string, PiAiModelOverride>
/**
* 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<PiAiModelProfile> = z.object({
id: z.string().required(),
const compatProfile: z<PiAiCompatProfile> = 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<PiAiReasoningEfforts>
/** 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<PiAiModelProfile> = z.object({
id: z.string().required(),
...modelFields,
})
/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */
const modelOverride: z<PiAiModelOverride> = 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,
})

View File

@@ -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'

View File

@@ -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<unknown> => 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 }])

View File

@@ -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<string, LlmPiAi.PiAiProviderProfile> {
return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } }
}
/** The first materialized model of one route, or throw. */
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
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 models 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<Api>)).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<LlmPiAi.PiAiModelProfile['reasoningEfforts']>): (() => 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<Api> => {
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<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
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 entrys 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<Api>[]
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 }])

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { Config } from '../src/config.ts'
describe('reasoning schema boundary', () => {
const configWith = (model: Record<string, unknown>): (() => 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<string, { models?: { reasoningEfforts?: unknown }[] }> }
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/)
})
})

View File

@@ -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

View File

@@ -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.

View File

@@ -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 的包可以消除该权宜之计。

View File

@@ -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',
}

View File

@@ -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<GenerateOptions>()
// 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;

View File

@@ -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

View File

@@ -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

View File

@@ -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` 占位值替换为真实端点。
## 模型体验

View File

@@ -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. */

View File

@@ -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

View File

@@ -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 `<skill_content>` 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

View File

@@ -37,6 +37,10 @@
| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 |
| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 |
### 共享的面向模型渲染
`renderSkillContent(skill)` 把一个已加载 skill 渲染为规范的 `<skill_content>` 块(转义后的 `name` 属性、资源提示、原样正文)。它是两条加载路径的唯一真源:`dsh-tool-skill` 将其作为 `skill` 工具结果返回,并在用户显式的手势边界将其注入,因此无论加载由谁发起,模型看到的都是同一种形态。`escapeText` 随之一并导出,供要在同一标记框架中嵌入文案的消费方使用。该包还声明 `skill-invocation` 这个 `MessageSource` kind{ name, form: 'instructions' }用户显式注入会把它打在自己的消息上——transcript文本记录消费方依据这份元数据呈现该次调用而不是重新解析正文。
`isModelInvocable(skill)``isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 仍是受信且与策略无关的加载原语,因此每个面向用户或模型的消费方都必须先执行与自身接口匹配的判定,再暴露或加载 skill。
## 提供方契约

View File

@@ -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"
}
}

View File

@@ -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<SkillSummary, 'invocation'>): 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 `<skill_content>` 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 `<skill_content>` block.
*/
export function renderSkillContent(skill: Pick<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${escapeAttr(skill.name)}">`,
'<skill_resources>',
...resourceHint,
'</skill_resources>',
'',
'<skill_instructions>',
skill.content,
'</skill_instructions>',
'</skill_content>',
].join('\n')
}
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
/**
* 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted invocation-neutral summaries collected in this observation. */

View File

@@ -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([
'<skill_content name="demo-skill">',
'<skill_resources>',
'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.',
'</skill_resources>',
'',
'<skill_instructions>',
'Do the thing.',
'</skill_instructions>',
'</skill_content>',
].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 <bundle>' },
content: 'Body.',
})
expect(opaque).toContain('Resources for this skill: archive &lt;bundle&gt;')
})
it('falls back to the provider hint without a resource base', () => {
const text = renderSkillContent({
name: 'provider-skill',
provider: 'remote <hub>',
content: 'Body.',
})
expect(text).toContain('Resources for this skill are managed by provider "remote &lt;hub&gt;".')
})
it('escapes hostile attribute names and keeps the body verbatim', () => {
const text = renderSkillContent({
name: 'x"&<y',
provider: 'memory',
resourceBase: { kind: 'directory', path: '/tmp' },
content: 'Keep </skill_content> and <tags> as-is.',
})
expect(text).toContain('<skill_content name="x&quot;&amp;&lt;y">')
expect(text).toContain('Keep </skill_content> and <tags> as-is.')
})
})

View File

@@ -15,6 +15,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../support/invariants"
}

View File

@@ -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

View File

@@ -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 `<available_skills>` 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 `<available_skills>` 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
</available_skills>
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 <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.
</system-reminder>
```
@@ -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 `<skill_content>` 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.

View File

@@ -36,7 +36,7 @@
#### 模型看到的内容
如果存在模型可调用 skill且可见的正是这个 `skill` 工具agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 skill 时,会追加一个空信封,并明确指示不得使用旧名称。
如果存在模型可调用 skill且可见的正是这个 `skill` 工具agent 会在第一个请求之前收到下方目录模板,其中包含每个已排序 skill 的一条随数据而定的条目。该目录是一条持久的用户角色消息。后续成员关系、描述或可见性的变化会使用同一个 `<available_skills>` 信封追加完整替换;删除所有 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
</available_skills>
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 <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.
</system-reminder>
```
@@ -144,6 +145,20 @@ Load referenced resources only as needed.
仅追加;新可见内容位于可重用请求前缀之后,不会使现有 KV Cache 条目失效。
### 用户显式调用注入
#### 模型看到的内容
已认领用户消息中任意位置、以空白为界、指名工作区目录中某个用户可调用 skill 的 `/name` token会把该 skill 的完整 `<skill_content>` 渲染(与上文结果模板完全相同的形态)作为 `user` 角色的指令上下文注入,追加在该步骤所有其他注入之后——背景在前,模型要着手处理的材料在最后。只扫描直接的用户输入,检查在已加载定义上进行,未知名称和用户不可调用的名称保持为普通行文。这是 `disable-model-invocation` skill 唯一的入口,目录和 `skill` 工具永不暴露这类 skill目录的结尾一句会告诉模型遵循注入块而不是重新加载它。
#### Token 影响
每次手势会把一份渲染后的 skill 正文作为注入上下文加进该轮次——尺寸与同一 skill 的工具结果相同,按用户的请求确定性地支付,而非由模型自行裁量。同一步骤内对同一 skill 的重复手势只注入一次。
#### KV Cache 影响
仅追加;注入落在该步骤的消息批次中、可重用请求前缀之后,不会使现有 KV Cache 条目失效。
## 已知限制与暂缓事项
- **目录省略 `whenToUse`、来源和提供方元数据**:路由只基于名称和有长度上限的描述;`whenToUse` 仍是提供方元数据,加载后的包装层也不渲染它。

View File

@@ -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 `/<name>` 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<PreStepDecision> => {
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<SkillDefinition, 'name' | 'provider' | 'resourceBase' | 'content'>): string {
const resourceHint = renderResourceHint(skill)
return [
`<skill_content name="${escapeAttr(skill.name)}">`,
'<skill_resources>',
...resourceHint,
'</skill_resources>',
'',
'<skill_instructions>',
skill.content,
'</skill_instructions>',
'</skill_content>',
].join('\n')
}
function renderResourceHint(skill: Pick<SkillDefinition, 'provider' | 'resourceBase'>): 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
'</available_skills>',
'',
"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 <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
'</system-reminder>',
].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 <skill_content> 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 <skill_content> 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
/**
* 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
/**
* `/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
}

View File

@@ -287,6 +287,7 @@ describe('dsh-tool-skill', () => {
'</available_skills>',
'',
"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 <skill_content> block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.',
'</system-reminder>',
].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<void> {
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('<skill_content name="hidden-demo">')
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'])
})
})