Merge origin/master into codex/status-bar-token-metrics

Resolve the agent-loop import conflict by retaining both durable request context and runtime policy context. Refresh the combined session fixtures and regenerate documentation catalogs. Mark PDF artifacts as binary so staged whitespace checks do not parse PDF bytes as text.
This commit is contained in:
Hypatia May
2026-07-31 16:53:03 +08:00
249 changed files with 72476 additions and 2957 deletions

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/bash/bash-sandbox/README.md
README.md: ca77a9c626784b29145712535d69de4afbd3a697
README.zh.md: 4ecc8d533f7af373bdacd133d44a8def6d265868
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb

View File

@@ -18,7 +18,7 @@ Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -44,15 +44,15 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
#### What the model sees
The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated.
The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The policy owner separately contributes the current capability-neutral `sandbox:policy` context.
#### Token effect
Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens.
Small fixed schema increment on requests where `bash` is visible, plus the current-policy clause owned by `dsh-sandbox-policy`.
#### KV Cache effect
Prefix-stable while the executor advertises the same sandbox capabilities. Changing those capabilities alters the `bash` schema and may invalidate reuse from that definition; per-session mode switches do not.
A standing-policy change appends a complete owner-rendered context snapshot after retained history, preserving the existing system/history prefix byte-for-byte. Changing executor capabilities alters the `bash` schema.
### Bash tool result, indirectly

View File

@@ -18,7 +18,7 @@
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
@@ -44,15 +44,15 @@
#### 模型看到的内容
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`后端不添加提示词文本,会话的有效模式仍不会声明
基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`策略归属方会另行贡献当前且不区分具体能力的 `sandbox:policy` 上下文
#### Token 影响
`bash` 可见的请求上schema 固定增加少量内容;模式切换不增加上下文 token
`bash` 可见的请求上schema 固定增加少量内容,另有一条由 `dsh-sandbox-policy` 负责的当前策略子句
#### KV Cache 影响
执行器持续公布相同沙箱能力时,前缀保持稳定。更改这些能力会改变 `bash` schema可能使从该定义起的复用失效每会话模式切换不会导致失效
常驻策略变化会在保留的历史之后追加一份由归属方渲染的完整上下文快照,并使既有 system/history 前缀保持逐字节不变。更改执行器能力会改变 `bash` schema
### 间接的 Bash 工具结果

View File

@@ -21,7 +21,7 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
* the default mode and fallback `workspace-write` root — is NOT here: it lives
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
* each calling session's mode and cwd for both enforcing families. The runner
* each calling session's mode and cwd for every enforcing capability. The runner
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
*/
export type Config = LocalConfig
@@ -30,8 +30,8 @@ export type Config = LocalConfig
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. Tool calls pass the calling session's resolved policy; direct
* calls fall back to deployment policy. The prompt does not state the standing
* mode; `result.sandbox` reports the mode and enforcement actually used.
* calls fall back to deployment policy. `result.sandbox` reports the mode and
* enforcement actually used.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static override inject = ['subprocess', 'sandbox', 'sandboxPolicy']

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/bash/tool-bash/README.md
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b
README.md: 29b9fba369e1fc6a4b8bb7bdd6543b7678df627d
README.zh.md: 31f691f7bfb8d2cb905751663151c3f6a6bc6c57

View File

@@ -71,7 +71,7 @@ Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` ap
## Per-session mode switching
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. The policy owner contributes the current capability-neutral standing mode; denial results still own the operation-specific effective mode and retry guidance. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
@@ -79,7 +79,7 @@ For sandboxing executors, each call resolves mode as one-shot escalation, then s
#### What the model sees
Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section.
Every request in this plugin's registration scope contains the bash guidance below. The policy owner contributes current sandbox state through its cache-safe runtime context rather than changing this section. Scoped tool restrictions can hide the schemas without removing this independently registered section.
##### Bash guidance

View File

@@ -71,7 +71,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
## 逐会话模式切换
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。提示词和切换通知均不公布当前常驻模式;拒绝结果会在边界相关时报告有效模式。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换契约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验
@@ -79,7 +79,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
#### 模型看到的内容
此插件注册作用域内的每个请求都包含下方 bash 指引。启用沙箱的执行器不会添加模式声明或切换通知。作用域工具限制可以隐藏 schema但不会移除这个独立注册的段落。
此插件注册作用域内的每个请求都包含下方 bash 指引。策略归属方通过自身的缓存安全运行时上下文贡献当前沙箱状态,而不改变此段落。作用域工具限制可以隐藏 schema但不会移除这个独立注册的段落。
##### Bash 指引

View File

@@ -376,7 +376,6 @@ export function apply(ctx: Context, config: Config = {}): void {
if (defaultMode !== undefined && sandboxPolicy === undefined) {
throw new Error('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing')
}
/** Resolve the complete standing policy for this call when a confining executor is mounted. */
const resolveSandboxPolicy = (exec: ToolExecution): SandboxExecutionPolicy | undefined =>
sandboxPolicy?.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })

View File

@@ -139,7 +139,34 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* Read-card sample for the read turn: a WINDOW past an offset, so the line
* numbers start above 1 (the card's gutter keeps the file's own numbering) and
* `totalLines` exceeds the window (the card shows a "showing N of M" note). The
* fixture is client-side and cannot import the read tool, so the structured
* window is authored inline exactly as the tool would project it through
* `presentationMeta`. `lang` is a `ts` hint so the shiki path highlights it.
*/
const READ_SAMPLE_FIRST_LINE = 41
const READ_SAMPLE_SOURCE = [
'export interface ReadBlockProps {',
' label?: string | undefined',
' lines: readonly ReadBlockLine[]',
' totalLines: number',
' lang?: string | undefined',
' maxLines?: number | undefined',
' className?: string | undefined',
'}',
'',
'// A windowed read keeps the file line numbers in the gutter.',
'const marker = "fixture read sample"',
]
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
const READ_SAMPLE_TOTAL = 180
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
/**
* The structured `web_search` result view for fixture turn 67, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
@@ -169,7 +196,7 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
/** The `web_fetch` result view for fixture turn 68, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
@@ -336,8 +363,8 @@ function buildAlphaLog(): SessionEvent[] {
const turn = 64
const callId = `fx-call-${turn}`
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
+ 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
+ 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
+ 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+ 'return { listing, demo }'
const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -362,8 +389,8 @@ function buildAlphaLog(): SessionEvent[] {
})
}
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
dispatchPair(2, 'read', { file_path: 'notes/demo.txt' }, 'hello fixture\n')
dispatchPair(3, 'read', { file_path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
push({
type: 'tool/result', surfaceOp: 'append',
data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
@@ -371,7 +398,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip.
const fixtureTodos = [
{ content: '梳理需求', status: 'completed' },
@@ -392,7 +419,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the web render intent — a web_search whose result view carries
// Turn 66: the read sample — a WINDOW past an offset so the card draws file
// line numbers starting above 1 and a "showing N of M" note (the window is
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
// The render-site fallback ROW SHAPE (a read call on the generic flattened
// path) is covered by the turn 64 run_code read sub-dispatches, which
// session.ts folds with resultView: null; the fallback-row + read-CARD
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
// this fixture. The read render intent is result-side only, so its pending
// call stays a generic `kind: 'read'` card; presentResult carries the
// structured window.
toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
// Turns 67-68: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
@@ -401,11 +441,11 @@ function buildAlphaLog(): SessionEvent[] {
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
toolTurn(67, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(68, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(69, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -440,6 +480,12 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
card: 'diff', title: `Write ${str(args.path)}`,
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
// A read pending call is a GENERIC card (kind: 'read', a follow-along
// location): the read render intent is result-side only, because a call
// carries no file content until execute returns. The rich read card arrives
// in presentResult.
case 'read':
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
case 'edit':
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
// scattered hunks share one path header and the card draws the `⋯` gap.
@@ -476,6 +522,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// The read result is the structured window the tool projects through
// `presentationMeta`; the fixture authors it inline (it cannot import the
// tool). Keyed on the name because the read pending call is a generic card,
// so `call.card` alone does not distinguish it from edit/write.
if (name === 'read') {
return {
card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
}
}
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract

View File

@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { readToolview } from './toolviews/read-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
@@ -320,6 +321,10 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The read row rides the same seam (a product registration, not a sample):
// Read · {path} chrome with the file's read card resident below it.
ctx.plugin(readToolview)
// The write/edit rows ride the same seam: a file-mutation call declares the
// diff render intent, so these rows stack the applied diff card under their
// path-link summary (the terminal card's posture, applied to diffs).

View File

@@ -1,7 +1,8 @@
/* The generic card grows a resident web card under its summary row when the
tool declares the `web` render intent but has no keyed row of its own (the
web_search/web_fetch rows register their own WebRow). A column around the
ToolRow keeps the row's own 24px height. */
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
without its own keyed row (e.g. web_fetch) grows a resident card under its
summary row. A column around the ToolRow keeps the row's own 24px height, so
the read card renders identically to the keyed ReadRow and the web card to
the web_search/web_fetch WebRow. */
.card {
display: flex;
@@ -10,6 +11,7 @@
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read,
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -7,9 +7,10 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14, WebBlock,
IconThinkOutline14, ReadBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
@@ -37,6 +38,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const read = readCardModel(block, cwd)
const diff = diffCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
@@ -69,6 +71,18 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
inspect={inspect}
/>
)
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
// so the file's read card is resident below the summary row exactly as the
// keyed ReadRow draws it. Only wrap when a card is present, so every other
// tool keeps the bare ToolRow.
if (read !== null) {
return (
<div className={css.card}>
{row}
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
</div>
)
}
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row

View File

@@ -0,0 +1,76 @@
/**
* Pure derivation of the read-card props from a frozen call slice: the
* `card:'read'` render intent the read tool declares arrives on the snapshot as
* the settled result node's `resultView`, and this is the one place that turns
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
* tool row's resident body and the details panel's Output section) call this, so
* the path, lines, total, and language they show are derived once.
*
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
* a call carries no file content until `execute` returns, so the pending call
* stays a generic card (`kind: 'read'`). A running read therefore has no read
* card, and this returns null for it — the row keeps its args-derived summary
* until the result arrives.
* @module
*/
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
/**
* Content lines the chat row's resident read body shows before collapsing the
* middle — half the primitive's own default, which the details panel keeps. A
* chat row is a summary surface inside the message flow: the flow must stay
* scannable across many calls, while the details panel is the single-call
* reading surface. A design constant of this UI's row geometry, not a
* deployment choice, so it is fixed here rather than a plugin Config field. The
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
* terminal output.
*/
export const CHAT_READ_MAX_LINES = 8
/**
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
* props so the two stay in step; `maxLines`/`className` belong to each render
* site.
*/
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
/**
* Derive the read-card props for a tool call, or null when this call is not a
* read card and belongs on the generic path.
*
* The read card is result-side only, so only a settled call whose result view
* declares `card:'read'` produces one. Every other case is null — the
* documented generic-card default:
*
* - A running call: it has no result view yet, and a read carries no content at
* call time.
* - A settled call whose result view is not a read card — including a `card`
* value this UI version does not know, which arrives over the wire and cannot
* be trusted to be one of the compiled variants, and the read tool's own
* generic fallback for an error result or a non-envelope body.
*
* The label is the read view's `title` when the tool supplied one (the
* presentation contract's replacement-title rule), otherwise the file path
* relativized to the session workspace so a workspace-rooted absolute path
* displays the same short form the row summary shows.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
* path label displays relative to it. Absent leaves the path as authored.
* @returns the read-card props, or null for the generic path.
*/
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
// Running has no result view; a read carries no content until execute returns.
if (!('kind' in block)) return null
const result = block.resultView?.card === 'read' ? block.resultView : null
if (result === null) return null
// Lines arrive frozen off the snapshot; copy into the primitive's own line
// shape so the card never holds a reference into the runtime's cache.
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
return {
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
lines,
totalLines: result.totalLines,
lang: result.lang,
}
}

View File

@@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
/**
* Strip the workspace root from a workspace-rooted absolute path (display only).
* @param text - the path to shorten.
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
*/
export function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)

View File

@@ -108,8 +108,9 @@
margin: 0;
}
/* Same rule for the web card: it sits under the section label, so the section
owns the spacing rather than the primitive's own vertical margin. */
/* The read and web cards sit directly under their section label, same as the
terminal card: drop the primitive's standalone vertical margin. */
.read,
.web {
margin: 0;
}

View File

@@ -7,10 +7,11 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
@@ -129,7 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. A diff-card call — a
* its alignment and scrolls sideways instead of folding. A read-card call
* renders through the shared ReadBlock at that same full height, so the whole
* returned window is line-numbered and highlighted. A diff-card call — a
* write/edit's applied change — renders through the shared DiffBlock at the same
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
* through WebBlock at its own full source-list allowance. Every other call, and
@@ -153,6 +156,10 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.
if (read !== null) return <ReadBlock {...read} className={css.read} />
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)

View File

@@ -0,0 +1,119 @@
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
the read card the row stacks under its summary line. */
/* Summary line over the read card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
gives the same executing feedback a running command row does. The leading
read icon stays static (a read has no per-step state to animate); the sweep
is the row-level running signal. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-read-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-read-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -0,0 +1,105 @@
// Read toolview registrant: the keyed toolview hole for the read tool
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
// file path as an openable link, exactly as the generic read row draws it.
//
// A read RESULT declares the read render intent, so this row renders the file's
// own line-numbered, syntax-highlighted content through ReadBlock resident
// below its summary line — the same posture BashRow gives a terminal card. The
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
// block's own default of 16) with the block's internal expander keeping a long
// read from taking over the message flow. A running read (no result yet) and a
// non-read result both render the summary row alone. The read intent is
// result-side only, so there is no running-state read card to draw.
import type { Context } from 'cordis'
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './read-row.module.css'
/** Leading-slot state substitution: the tool icon yields to the state dot
* (error = red, interrupted = amber). Running keeps the icon. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconBrowseOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
* read card resident below it. The summary path is an openable host link when
* the row names a single file; the card's copy and expand controls plus that
* link are the row's only interactions (tool rows are not details-panel
* targets).
*/
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
// Session workspace root: the read view's path relativizes against it (a
// workspace-rooted absolute path shows its short form), which the pure
// presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const model = toolRowModel(toolName, block, cwd)
const read = readCardModel(block, cwd)
const status = stateStatus(model.state)
const filePath = model.filePath
return (
<div className={css.card}>
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
sep, path-link/summary) is the shared ToolRow row shape every keyed
toolview draws; extracting it into one component is a separate change
tracked for all rows at once, not this read-card PR. */}
<div className={css.root} data-variant="read" data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{/* jscpd:ignore-end */}
{read !== null && (
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
)}
</div>
)
}
/**
* The read row as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and with
* it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
/**
* Register the read row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
},
}

View File

@@ -84,14 +84,14 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card; the
// web rows register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -0,0 +1,293 @@
// @vitest-environment jsdom
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each with the read card resident under the summary) and the details panel's
// Output section. Also pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** The chat-view locale seat: this package's namespace over the common fallback. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
// The read tool's real schema key is `file_path`; the top-level read samples
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
function contentTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
}
/** Three windowed lines starting at file line 41 (a read past an offset). */
const sampleLines = [
{ number: 41, text: 'export const a = 1' },
{ number: 42, text: 'export const b = 2' },
{ number: 43, text: 'export const c = 3' },
]
/** The read tool's own result view for a settled file read. */
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
})
describe('readCardModel', () => {
it('derives the card from a settled read result view', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
})
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
const model = readCardModel(settled())
expect(model?.lines).toEqual(sampleLines)
expect(model?.lines).not.toBe(sampleLines)
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
it('takes the result view\'s replacement title over the relativized path', () => {
// The presentation contract defines a result title as REPLACING the pending
// one, so a tool that supplies a label wins over the path here.
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
.toBe('Read (head) src/a.ts')
})
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('carries an omitted language through as undefined', () => {
const noLang = resultRead()
delete (noLang as { lang?: string }).lang
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
// A read carries no content until execute returns, so the pending call is a
// generic card and there is no read card to draw yet.
expect(readCardModel(running())).toBeNull()
})
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
expect(readCardModel(settled({ resultView: null }))).toBeNull()
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(readCardModel(settled({ resultView: future }))).toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
it('renders the read card resident under the summary, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the resident read block.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
expect(view.getByText('41')).toBeTruthy()
})
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
callId: 'c1', toolName: 'echo', block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('a running read renders the summary row alone (no result view yet)', () => {
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the file path summary and the resident read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// The path appears twice: the row summary link and the card's banner label.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('the path summary opens the file through the host', () => {
const openFile = vi.fn()
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
// The row derives the file path from args; the chat view resolves it against
// the cwd before this callback opens it, so the arg path is what arrives.
expect(openFile).toHaveBeenCalledWith('src/a.ts')
})
it('a running read renders the summary row alone, and its state', () => {
const view = render(<ReadRow {...rowProps(running())} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an error read result shows the error state and no read card', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: { card: 'generic' }, isError: true,
content: [{ type: 'text', text: 'ENOENT' }],
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an interrupted read shows the stopped state', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
expect(readToolview.inject).toContain('conversation')
})
})
describe('DetailsPanel Output section (read)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
t={t}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16}`)).toBeTruthy()
expect(contentTexts(view.container)).toContain('row-0')
})
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
})
it('a running read keeps the 运行中… placeholder (no result view)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})

View File

@@ -0,0 +1,117 @@
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
markdown code-block font) so a read card and a fenced code block read as one
family. Content keeps `white-space: pre` and scrolls horizontally rather than
folding, because a source line's indentation is part of what a reader is
reading. */
.block {
--dsl-read-radius: 12px;
--dsl-read-line-height: 22px;
/* Fixed-width gutter column for the line numbers, so the content edge stays
put down the whole window regardless of how wide the numbers grow. */
--dsl-read-gutter: 48px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-read-radius);
}
.banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 9px 14px;
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-read-radius);
border-top-right-radius: var(--dsl-read-radius);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.count {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}
.lang {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
}
.copyButton {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 0;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* One row per file line: a fixed gutter column, then the content. No wrapping —
a source line's leading whitespace is meaningful and scrolls sideways. */
.line {
display: flex;
min-height: var(--dsl-read-line-height);
line-height: var(--dsl-read-line-height);
white-space: pre;
}
.gutter {
flex: none;
width: var(--dsl-read-gutter);
padding-right: 14px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
/* The gutter is chrome, not content: keep it out of a text selection so a
copy of the visible rows carries the source, not the line numbers. */
user-select: none;
}
.content {
color: var(--dsw-alias-label-primary);
}
.expand {
display: block;
width: 100%;
padding: 0 0 0 var(--dsl-read-gutter);
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}

View File

@@ -0,0 +1,172 @@
// ReadBlock: the file surface for a read tool result — a banner (label +
// language + a "showing N of M" note when the read is a window + a copy
// control) over line-numbered, syntax-highlighted source. Each row carries the
// file's OWN line number in a gutter, so a windowed read past an offset keeps
// its file numbering rather than re-counting from 1. Highlighting reuses the
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
// needs; an unknown or absent language renders plain monospace. Long content is
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
// two cards collapse a long body at the same place. Colors resolve through
// --shiki-*/--dsw-* tokens.
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import {
grammarLoadCount,
highlightLines,
subscribeGrammarLoaded,
type HighlightSpan,
} from './markdown/highlight.ts'
import css from './ReadBlock.module.css'
/**
* Content lines shown before the height cap collapses the middle. Matches
* TerminalBlock's default so a long read and a long command output cut at the
* same place in the same flow.
*/
export const DEFAULT_READ_MAX_LINES = 16
/** One line of the read window: its file line number and its text (no trailing newline). */
export interface ReadBlockLine {
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
number: number
/** The line's text, already truncated to the read tool's per-line cap. */
text: string
}
export interface ReadBlockProps {
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
label?: string | undefined
/** The returned window's lines, in file order, each keeping its file line number. */
lines: readonly ReadBlockLine[]
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
totalLines: number
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
lang?: string | undefined
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/**
* Render one line's highlighted runs. The css-variables theme colors every run,
* so each run is a styled span; a line with no highlighting at all takes the
* bare-text path in the caller instead (an unknown or absent language).
* @param spans - the line's styled runs.
* @returns the line's children.
*/
function renderSpans(spans: readonly HighlightSpan[]) {
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
}
/**
* Render a read tool result as a line-numbered, optionally syntax-highlighted
* file view.
* @param props - see {@link ReadBlockProps}.
* @returns the read block element.
*/
export function ReadBlock({
label,
lines,
totalLines,
lang,
maxLines = DEFAULT_READ_MAX_LINES,
className,
}: ReadBlockProps) {
// The raw text the copy control writes and the highlighter tokenizes: the
// window's lines joined by newlines, without the file numbers or any chrome.
// Highlighting the whole window in one call (not line by line) keeps grammar
// context across lines — a multi-line string or comment stays one construct.
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
// Re-render when a lazy grammar finishes loading, so a read card that showed
// plain text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
// unknown/absent (or not-yet-loaded) language, when every line renders as
// bare text.
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
// The window's raw text, never the rendered tree: the gutter numbers and the
// banner are chrome the file does not contain.
void writeClipboard(raw).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, raw])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = lines.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
// long command output slice their head and tail at the same place.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
// A read is a window when its returned lines are fewer than the file's total;
// the note states that so a reader is not misled that the file ends here.
const windowed = lines.length < totalLines
/**
* Render a slice of the line array as gutter-numbered rows.
* @param slice - the lines to draw, each with its aligned run array.
* @returns the row elements.
*/
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
slice.map(([line, spans]) => (
<div key={line.number} className={css.line}>
<span className={css.gutter} aria-hidden>{line.number}</span>
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
</div>
))
// Pair each line with its aligned run array up front, so head/tail slicing
// keeps the two in step without re-indexing.
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
[line, highlighted?.[index]])
return (
<div className={clsx(css.block, className)} data-read="">
<div className={css.banner}>
<div className={css.label}>{label ?? ''}</div>
<div className={css.action}>
{windowed && (
<span className={css.count}>{`显示 ${lines.length} / ${totalLines}`}</span>
)}
<span className={css.lang}>{lang ?? ''}</span>
{/* Hide copy on an empty window, matching TerminalBlock's empty-output
guard: a successful read of an empty file returns lines: [] with
card:'read', so this branch is reachable, and copying then would
wipe the clipboard with an empty string. */}
{lines.length > 0 && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
)}
</div>
</div>
<div className={css.body}>
{rows(capped ? paired.slice(0, headLines) : paired)}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起内容' : `展开其余 ${hidden}`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{capped && rows(paired.slice(paired.length - tailLines))}
</div>
</div>
)
}

View File

@@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'

View File

@@ -4,10 +4,10 @@
// plain fallback for everything else. Chrome (language banner + copy) matches
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { highlightToHtml } from './highlight.ts'
import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -25,7 +25,11 @@ export interface CodeBlockProps {
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
const rootRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)

View File

@@ -5,10 +5,17 @@
* theme package's token sheets as `--shiki-*` custom properties (light and
* dark blocks), never here — the repo's tokens-only styling rule.
*
* Grammars are the set the harness actually renders: TypeScript programs
* (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands,
* and JSON payloads. An unknown or absent language falls back to plain text
* (no highlighting, still monospace) — never an error.
* Only the three markdown-fence and `run_code` grammars (TypeScript, shell,
* JSON) load into the singleton at boot — the set every session renders. The
* read card's wider extension set (the file-extension language hints the read
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
* markup, …) is imported lazily and registered the first time such a language
* is requested, so a session that never opens a read card in one of those
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
* init. The first render of a lazy language falls back to plain text while its
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
* with highlighting. An unknown or absent language falls back to plain text (no
* highlighting, still monospace) — never an error.
*/
import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core'
@@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
import type { HighlighterCore } from 'shiki/core'
import type { CSSProperties } from 'react'
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
* from a boot grammar so no direct `@shikijs/types` dependency is needed. */
type LangModule = { default: typeof langTs }
/**
* Language ids (and aliases) the singleton registers; everything else renders
* Grammars the singleton loads at boot; each entry's own `name` is the id
* `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx)
* resolve to the TypeScript grammar rather than a separate one: it tokenizes
* plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the
* dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted
* trade to keep the boot set to one JS-family grammar. The read card's wider
* set loads lazily through {@link LAZY_GRAMMARS}.
*/
const LANGS = [langTs, langBash, langJson]
/**
* The read card's extension grammars, each behind a dynamic import so its
* module stays out of the boot chunk until a read of that language renders.
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
* hands the whole array to `loadLanguageSync`, which registers each entry
* (including embedded sub-grammars). The three boot grammars are absent —
* already loaded, so no alias value ever points at a missing entry here.
*/
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
['python', () => import('@shikijs/langs/python')],
['ruby', () => import('@shikijs/langs/ruby')],
['go', () => import('@shikijs/langs/go')],
['rust', () => import('@shikijs/langs/rust')],
['java', () => import('@shikijs/langs/java')],
['c', () => import('@shikijs/langs/c')],
['cpp', () => import('@shikijs/langs/cpp')],
['csharp', () => import('@shikijs/langs/csharp')],
['kotlin', () => import('@shikijs/langs/kotlin')],
['swift', () => import('@shikijs/langs/swift')],
['php', () => import('@shikijs/langs/php')],
['yaml', () => import('@shikijs/langs/yaml')],
['toml', () => import('@shikijs/langs/toml')],
['ini', () => import('@shikijs/langs/ini')],
['markdown', () => import('@shikijs/langs/markdown')],
['mdx', () => import('@shikijs/langs/mdx')],
['html', () => import('@shikijs/langs/html')],
['css', () => import('@shikijs/langs/css')],
['scss', () => import('@shikijs/langs/scss')],
['less', () => import('@shikijs/langs/less')],
['sql', () => import('@shikijs/langs/sql')],
['xml', () => import('@shikijs/langs/xml')],
['lua', () => import('@shikijs/langs/lua')],
])
/**
* Language ids (and aliases) the highlighter accepts; everything else renders
* plain. A Map, not an object: fence info strings are assistant-authored, so
* a label like `constructor` or `__proto__` must miss instead of resolving an
* inherited property and crashing the renderer inside shiki.
* inherited property and crashing the renderer inside shiki. Keys cover both
* the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids
* the read tool's `langFromPath` emits, so both callers resolve the same
* grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for
* the JSX/TSX approximation), unchanged from when this was the only
* non-shell/JSON grammar. A value not in {@link LANGS} names a
* {@link LAZY_GRAMMARS} entry loaded on first use.
*/
const LANG_ALIASES = new Map<string, string>([
['typescript', 'typescript'],
@@ -30,6 +94,7 @@ const LANG_ALIASES = new Map<string, string>([
['tsx', 'typescript'],
['javascript', 'typescript'],
['js', 'typescript'],
['jsx', 'typescript'],
['shellscript', 'shellscript'],
['bash', 'shellscript'],
['sh', 'shellscript'],
@@ -37,6 +102,35 @@ const LANG_ALIASES = new Map<string, string>([
['zsh', 'shellscript'],
['json', 'json'],
['jsonc', 'json'],
['py', 'python'],
['python', 'python'],
['rb', 'ruby'],
['ruby', 'ruby'],
['go', 'go'],
['rs', 'rust'],
['rust', 'rust'],
['java', 'java'],
['c', 'c'],
['cpp', 'cpp'],
['cs', 'csharp'],
['csharp', 'csharp'],
['kotlin', 'kotlin'],
['swift', 'swift'],
['php', 'php'],
['yaml', 'yaml'],
['yml', 'yaml'],
['toml', 'toml'],
['ini', 'ini'],
['md', 'markdown'],
['markdown', 'markdown'],
['mdx', 'mdx'],
['html', 'html'],
['css', 'css'],
['scss', 'scss'],
['less', 'less'],
['sql', 'sql'],
['xml', 'xml'],
['lua', 'lua'],
])
/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */
@@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined
function highlighter(): HighlighterCore {
singleton ??= createHighlighterCoreSync({
themes: [cssVariablesTheme],
langs: [langTs, langBash, langJson],
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
return singleton
}
/** Grammar ids whose lazy import is in flight or done, so it is requested once. */
const requested = new Set<string>()
/** Subscribers re-rendered after a lazy grammar registers (React callers). */
const listeners = new Set<() => void>()
/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */
let loadCount = 0
/**
* Subscribe to lazy-grammar load completions; `listener` fires after a
* {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a
* caller that rendered its plain fallback while the grammar loaded can
* re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with
* {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function.
* @param listener - invoked (no args) on each grammar-load completion.
* @returns a disposer that removes the listener.
*/
export function subscribeGrammarLoaded(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
/**
* The lazy-grammar load counter — a value that changes on every load, so a
* `useSyncExternalStore` snapshot re-renders the subscriber when a grammar
* registers. Opaque: only its identity across renders matters.
* @returns the current load count.
*/
export function grammarLoadCount(): number {
return loadCount
}
/**
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
* synchronously; a lazy grammar not yet loaded starts its import (once) and
* reports not-ready, so the caller renders plain until a
* {@link subscribeGrammarLoaded} listener fires.
* @param resolved - the grammar id an alias resolved to.
* @returns whether the grammar is registered and ready to tokenize now.
*/
function ensureGrammar(resolved: string): boolean {
const load = LAZY_GRAMMARS.get(resolved)
// A boot grammar (already registered) has no lazy loader; it is always ready.
if (load === undefined) return true
if (highlighter().getLoadedLanguages().includes(resolved)) return true
if (!requested.has(resolved)) {
requested.add(resolved)
void load().then((mod) => {
highlighter().loadLanguageSync(mod.default)
loadCount += 1
for (const listener of listeners) listener()
})
}
return false
}
// Engine + grammar construction costs a long task (~120-175ms); building it
// during the first finalized fence's render would jank exactly when a stream
// completes. Warm the singleton in a deferred task at module load (= plugin
@@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0)
/**
* Highlight `code` into shiki's HTML (a single `<pre class="shiki">` tree)
* when `lang` maps to a registered grammar; `undefined` means the caller
* renders its plain fallback.
* renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
* for this call and loads in the background; subscribe with
* {@link onGrammarLoaded} to re-highlight once it registers.
* @param code - the source text.
* @param lang - the language hint (a markdown fence info string or a fixed caller id).
* @returns the highlighted HTML, or `undefined` for unknown languages.
* @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
}
/**
* One highlighted run of a line: the text and the inline style shiki assigned
* it. The css-variables theme colors every run through a `--shiki-*` custom
* property, so `style.color` is always present; it is held as a style object
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
*/
export interface HighlightSpan {
text: string
style: CSSProperties
}
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
* A line-numbered view needs the token runs split per line (one gutter number
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
* css-variables theme carries no font-style bits, matching that path's
* color-only output. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
* @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
*/
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
if (resolved === undefined) return undefined
if (!ensureGrammar(resolved)) return undefined
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
// third, empty line the caller's own line array does not carry. Drop that
// one terminator line so the two structures stay in step. The explicit
// `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
// per-file coverage, matching TerminalBlock's terminator check.
const last = tokens[tokens.length - 1]
const lines = tokens.length > 1 && last !== undefined && last.length === 0
? tokens.slice(0, -1)
: tokens
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
}

View File

@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
expect(highlightToHtml('x', undefined)).toBeUndefined()
})
// Every read-tool language hint whose grammar loads lazily (the boot set —
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
const LAZY_ALIASES = [
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
'xml', 'lua',
]
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
// First touch returns the plain fallback (undefined) and starts the import.
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
// Once every grammar has registered, the same call highlights.
await vi.waitFor(() => {
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
})
})
})
describe('CodeBlock', () => {

View File

@@ -0,0 +1,241 @@
// @vitest-environment jsdom
// ReadBlock + the highlightLines token path: the banner (label, language, the
// "showing N of M" note only when the read is a window, copy control), the
// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
// highlighting resolved to css-variables token spans with an identical-geometry
// plain fallback for an unknown/absent language, the head/tail height cap and
// its expand control, and the copy control writing the raw window text on both
// the accepted and refused clipboard paths.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** `count` lines starting at `first`, each with distinct text. */
function lines(count: number, first = 1): ReadBlockLine[] {
return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
}
/** The rendered rows as `<gutter><content>` strings (CSS-module class prefix). */
function rowTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The gutter numbers of the rendered rows, in order. */
function gutters(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
}
describe('highlightLines', () => {
it('tokenizes a registered grammar into per-line css-variables runs', () => {
const result = highlightLines('const x = 1\n// c', 'ts')
expect(result).not.toBeUndefined()
expect(result).toHaveLength(2)
// The keyword run carries a color style through a --shiki-* custom property.
const keyword = result![0]!.find(span => span.text === 'const')
expect(keyword?.style?.color).toContain('var(--shiki-')
// Whitespace between tokens is a run of its own; the comment is line two.
expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
expect(result![1]!.map(span => span.text).join('')).toBe('// c')
})
it('colors every run through a --shiki-* custom property', () => {
// The css-variables theme colors even the whitespace run (as the foreground
// token), so every run is a styled span; the plain fallback is the whole
// unknown-language path, not a per-run one.
const result = highlightLines('const x = 1', 'ts')
for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
})
it('drops the trailing terminator line so the run count matches the source lines', () => {
// `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
// line array has one entry, so the terminator line is dropped.
const result = highlightLines('const a = 1\n', 'ts')
expect(result).toHaveLength(1)
})
it('keeps a genuinely blank final line when the source ends in two newlines', () => {
const result = highlightLines('a\n\n', 'ts')
expect(result).toHaveLength(2)
expect(result![1]).toEqual([])
})
it('returns undefined for an unknown or absent language', () => {
expect(highlightLines('x', 'cobol')).toBeUndefined()
expect(highlightLines('x', undefined)).toBeUndefined()
})
it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
// A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
// not, so the first call renders plain and imports the grammar, and a
// subscriber fires once it registers, after which the same call highlights.
let notified = 0
const stop = subscribeGrammarLoaded(() => { notified += 1 })
// First touch: grammar not loaded yet, so plain fallback while it imports.
expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
// The import + loadLanguageSync resolve on a microtask; wait for the notify.
await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
expect(grammarLoadCount()).toBeGreaterThan(0)
const result = highlightLines('def f(): pass', 'py')
expect(result).not.toBeUndefined()
// `def` is a python keyword and carries a --shiki-* color once highlighted.
const keyword = result!.flat().find(span => span.text === 'def')
expect(keyword?.style?.color).toContain('var(--shiki-')
stop()
})
})
describe('ReadBlock rows', () => {
it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
expect(gutters(view.container)).toEqual(['41', '42', '43'])
expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
})
it('highlights the content for a known language into token spans', () => {
const view = render(
<ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
expect(content?.textContent).toBe('const a = 1')
})
it('renders the content as bare text with no span wrappers for an unknown language', () => {
const view = render(
<ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(content?.textContent).toBe('IDENT DIVISION.')
})
it('renders bare text when no language is given', () => {
const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
const content = view.container.querySelector('[class^="_content_"]')
expect(content?.querySelectorAll('span').length).toBe(0)
expect(view.getByText('plain')).toBeTruthy()
})
})
describe('ReadBlock banner', () => {
it('shows the label, the language, and the count note when the read is a window', () => {
const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(view.getByText('ts')).toBeTruthy()
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('omits the count note when the window is the whole file', () => {
const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
expect(view.queryByText(//u)).toBeNull()
})
it('draws an empty label and empty language when neither is given', () => {
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
})
})
describe('ReadBlock height cap', () => {
it('renders every line and no expand control under the cap', () => {
const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
expect(rowTexts(view.container)).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
const toggle = view.getByRole('button', { name: '展开其余 6 行' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 行')
fireEvent.click(toggle)
expect(rowTexts(view.container)).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起内容' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
expect(gutters(view.container)).toEqual(['1'])
expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {
const view = render(
<ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
)
expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
})
})
describe('ReadBlock copy', () => {
it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('copies the whole window while the height cap hides its middle', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
})
it('does not claim success when the host refuses the write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('merges className onto the wrapper', () => {
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
})
it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
// A successful read of an empty file settles to lines: [] with card:'read',
// so this branch is reachable; copying then would clear the clipboard.
const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
})

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/compact/compact-basic/README.md
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158
README.md: b35e5dc110e908047338054337b309a77b7e0f68
README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2

View File

@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
Rules:
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
- Capture user feedback and explicit instructions faithfully, especially corrections.
- Do NOT mention this summarization request or that the context was compacted.
- Output only the checkpoint text: do not call any tool or take any other action.

View File

@@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t
- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]
Rules:
- Preserve exact file paths, commands, error strings, identifiers, and function signatures.
- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.
- Capture user feedback and explicit instructions faithfully, especially corrections.
- Do NOT mention this summarization request or that the context was compacted.
- Output only the checkpoint text: do not call any tool or take any other action.

View File

@@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
'',
'Rules:',
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
'- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.',
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
'- Do NOT mention this summarization request or that the context was compacted.',
'- Output only the checkpoint text: do not call any tool or take any other action.',

View File

@@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => {
expect(messages[0]).toEqual(prefix)
const last = messages.at(-1)?.content[0]
const lastText = last?.type === 'text' ? last.text : ''
expect(lastText).toContain('Condense the conversation ABOVE')
expect(lastText).toContain('Write concise English engineering prose.')
expect(lastText).toContain('numeric values, function signatures, and syntax fragments.')
expect(lastText).toContain('## Primary Request and Intent')
})

View File

@@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(adapter.conversationRequests).toHaveLength(2)
expect(adapter.summaryRequests).toHaveLength(1)
const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('') ?? ''
expect(instruction).toContain('Write concise English engineering prose.')
expect(instruction).toContain('numeric values, function signatures, and syntax fragments.')
expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL')
const retry = JSON.stringify(adapter.conversationRequests[1]!.messages)
expect(retry).toContain('RECOVERY CHECKPOINT')

View File

@@ -916,6 +916,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'section(section: PromptSection): () => void',
jsDoc: '/**\n * Register an ordered prompt section in the calling context\'s scope. A scoped\n * section shadows a global section with the same name; duplicates within one\n * layer and non-finite orders throw. Registration and disposal emit\n * `system-prompt/change`.\n * @param section - the section to register.\n * @returns the exact Cordis effect disposer.\n */',
},
{
signature: 'context(context: PromptContext): () => void',
jsDoc: '/**\n * Register ordered cache-safe dynamic context in the calling context\'s scope.\n * A scoped context shadows a global context with the same name; duplicates\n * within one layer and non-finite orders throw. Registration and disposal\n * emit `system-prompt/change`.\n * @param context - the context contribution to register.\n * @returns the exact Cordis effect disposer.\n */',
},
{
signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void',
jsDoc: '/**\n * Register a tool-schema provider in the calling context\'s scope. Global and\n * matching scoped providers both contribute; returning the reserved\n * {@link TOOL_ORDER_REST} name makes assembly fail.\n * @param provider - evaluated for each assembly with its context.\n * @returns the exact Cordis effect disposer.\n */',
@@ -1446,8 +1450,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'system-prompt/assemble',
mode: 'waterfall',
signature: '\'system-prompt/assemble\'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>',
jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
summary: 'Expert waterfall over the assembled sections, tools, and variables.',
jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */',
summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.',
},
{
name: 'system-prompt/change',
@@ -1619,6 +1623,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AssembleContext',
declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n signal?: AbortSignal;\n}',
},
{
name: 'AssembledContext',
declaration: 'export interface AssembledContext {\n name: string;\n text: string;\n}',
},
{
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
@@ -2121,7 +2129,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n contexts: AssembledContext[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptContext',
declaration: 'export interface PromptContext {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
},
{
name: 'PromptSection',

View File

@@ -34,6 +34,7 @@ import {
LlmError,
assertNever,
createAssistantMessage,
createUserMessage,
deepFreeze,
errorChain,
freezeMessage,
@@ -45,7 +46,7 @@ import {
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -54,6 +55,47 @@ type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
const RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
/** Clearing marker kept distinct from every prefixed {@link renderContextSnapshot} result. */
const CLEARED_RUNTIME_CONTEXT = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.'
/** Whether one user message is owned by runtime-context materialization. */
function isRuntimeContextMessage(message: UserMessage): boolean {
return message.source.kind === 'plugin' && message.source.plugin === RUNTIME_CONTEXT_SOURCE
}
/** Latest retained runtime-context snapshot; `found` distinguishes malformed content from absence. */
function retainedRuntimeContext(session: Session): { found: boolean; text: string | undefined } {
const events = session.events
const nodes = session.surface.nodes
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const event = events[nodes[index] as number]
if (event?.type !== 'user/message' || !isRuntimeContextMessage(event.data)) continue
const [block] = event.data.content
return {
found: true,
text: event.data.content.length === 1 && block?.type === 'text' ? block.text : undefined,
}
}
return { found: false, text: undefined }
}
/** Append a full current snapshot only when it changed or compaction removed it. */
function materializeRuntimeContext(session: Session, current: string): void {
const previous = retainedRuntimeContext(session)
if (!previous.found && current.length === 0) {
const compactedPriorSnapshot = session.surface.replaceGeneration > 0
&& session.events.some(event => event.type === 'user/message' && isRuntimeContextMessage(event.data))
if (!compactedPriorSnapshot) return
}
const snapshot = current.length === 0 ? CLEARED_RUNTIME_CONTEXT : current
if (previous.text === snapshot) return
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: snapshot }],
source: { kind: 'plugin', plugin: RUNTIME_CONTEXT_SOURCE },
}), { surfaceOp: 'append' })
}
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -520,10 +562,13 @@ export class ReactLoopAgent implements Agent {
// this request together.
this.drainOutbox(turn)
// Assemble the system prompt fresh each step (it may depend on log state).
// Assemble request-owned prompt inputs fresh each step. Dynamic context is
// committed at the tail before deriving history once, preserving the stable
// system/history cache prefix while keeping every model-visible byte logged.
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const system = renderPrompt(assembly)
materializeRuntimeContext(session, renderContextSnapshot(assembly))
// Snapshot the exact log prefix: the reconstruction boundary. Appends
// after this synchronous snapshot join the next request.

View File

@@ -254,7 +254,7 @@ describe('agent loop', () => {
// NO system field at all (not an empty string).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
ctx.on('system-prompt/assemble', async () => ({ sections: [], contexts: [], tools: [], variables: {} }))
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
@@ -264,6 +264,178 @@ describe('agent loop', () => {
expect('system' in adapter.requests[0]!).toBe(false)
})
it('materializes changed runtime context at the history tail without rewriting the system header', async () => {
const adapter = new MockAdapter([
textResponse('one'),
textResponse('two'),
textResponse('three'),
textResponse('four'),
textResponse('five'),
])
const ctx = await harness(adapter)
let mode = 'read-only'
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' })
const contextEvents = () => agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(1)
expect(contextEvents()[0]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
}])
send(agent, 'unchanged')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(1)
mode = 'danger-full-access'
send(agent, 'changed')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(2)
const changedBlock = contextEvents()[1]?.data.content[0]
expect(changedBlock?.type).toBe('text')
if (changedBlock?.type !== 'text') throw new Error('changed runtime context is not text')
expect(changedBlock.text).toContain('danger-full-access')
dispose()
send(agent, 'cleared')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(3)
expect(contextEvents()[2]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
}])
send(agent, 'still clear')
await waitForIdle(ctx, agent)
expect(contextEvents()).toHaveLength(3)
expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system))
expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1)
})
it('re-emits unchanged runtime context when a surface replacement removed the retained snapshot', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
})
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
expect(adapter.requests[1]?.messages.some(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(true)
})
it('clears compacted runtime context after the active set becomes empty', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-compacted-clear'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')
if (contextEvent?.type !== 'user/message') throw new Error('first turn did not materialize runtime context')
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary retaining old mode: read-only' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: contextEvent.seq, end: contextEvent.seq },
sourceEventSeqs: [contextEvent.seq],
})
dispose()
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
const clearing = adapter.requests[1]?.messages.find(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')
expect(clearing?.content).toEqual([{
type: 'text',
text: 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.',
}])
})
it('does not clear runtime context after an unrelated replacement', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-unrelated-compaction'), { provider: 'mock', model: 'mock' })
const original = agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'old context' }],
source: { kind: 'plugin', plugin: 'test-context' },
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted summary' }],
source: { kind: 'plugin', plugin: 'test-compaction' },
}), {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})
send(agent, 'after compaction')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]?.messages.some(message =>
message.source.kind === 'plugin'
&& message.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false)
})
it('replaces a malformed retained runtime-context message with the current complete snapshot', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'Mode: read-only.' })
const agent = ctx.agentLoop.create(SessionId('a-runtime-context-malformed'), { provider: 'mock', model: 'mock' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'broken' }, { type: 'text', text: 'snapshot' }],
source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
}), { surfaceOp: 'append' })
send(agent, 'repair context')
await waitForIdle(ctx, agent)
const runtimeContexts = agent.session.events.flatMap(event =>
event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt'
? [event]
: [])
expect(runtimeContexts).toHaveLength(2)
expect(runtimeContexts[1]?.data.content).toEqual([{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.',
}])
})
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)

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/core/system-prompt/README.md
README.md: 23bc0e8177ad2a778df9522e254bfd5e03a9871f
README.zh.md: 1fd4febc1c15acda19e7abfca94079b9585c1972
README.md: d4e0f69323b7326fc7575834bf48a5aeeec0777e
README.zh.md: 47290335d725083fc46ef4f2ee09b09263276788

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
Model-input assembly registry. Plugins contribute ordered stable system sections, cache-safe dynamic context, tool schemas, and named variables. The loop assembles once per step, renders stable sections as the system prompt, and appends a durable full dynamic-context snapshot only when its text changes or compaction removed the retained snapshot. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default.
## Config
@@ -17,6 +17,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
### Public API
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute cache-safe dynamic model context. Contexts are ordered independently from system sections; scoped contributions shadow same-named globals. The agent loop materializes the complete current set as one sourced user-role snapshot after retained history, only when changed or missing. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
@@ -29,14 +30,17 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame.
- `PromptSection``{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100199`.
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `PromptContext``{ name, order, text }`. Contexts carry changing current facts that must not rewrite the cached system/history prefix; they use the same per-assembly provider and strict-variable contracts as sections.
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section and context texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
- `renderContextSnapshot(assembly)` — applies the same strict interpolation to contexts, drops empty entries, and emits one full snapshot with an explicit supersession statement. An empty active set returns `''`; the loop emits one clearing snapshot when previously visible context disappears.
Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `AssembleContext` via declaration merging.
### Extension points
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
- Context providers: policy and other changing-state owners contribute complete current facts without mutating the stable system prompt.
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
@@ -65,6 +69,20 @@ Identity is a fixed per-request cost when enabled. Persona and plugin text are r
Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token.
### Dynamic runtime context
#### What the model sees
Active contexts are joined in deterministic order after strict interpolation and logged as one sourced user-role message immediately before the request that first needs that snapshot. The message begins `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` A changed snapshot is appended after retained history; an unchanged retained snapshot adds nothing. If compaction removes it, the current full snapshot is emitted again. Removing the last context emits one explicit clearing snapshot.
#### Token effect
One concise message on the first request, on an effective context change, after compaction removed the retained snapshot, or when the active set becomes empty. Unchanged steps add no duplicate tokens.
#### KV Cache effect
Append-only after retained history. A context change preserves the previously cached system and conversation prefix instead of rewriting the first wire message.
### Tool schemas
#### What the model sees

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
系统提示词组装注册表。插件贡献有序、工具 schema 和具名变量。循环在每个步骤组装一次,并将结果渲染为完整的模型提示词。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
模型输入组装注册表。插件贡献有序且稳定的系统段、缓存安全的动态上下文、工具 schema 和具名变量。循环在每个步骤组装一次,将稳定段渲染为系统提示词并且仅在文本变化或压缩compaction移除了保留的快照时追加一份持久的完整动态上下文快照。此插件拥有静态 harness 身份和全局部署 personaagent智能体作用域的 persona 会遮蔽全局默认值。
## 配置
@@ -17,6 +17,7 @@
### 公开 API
- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose资源释放
- `ctx.systemPrompt.context(context: PromptContext): () => void`贡献缓存安全的动态模型上下文。上下文与系统段分别排序带作用域的贡献会遮蔽同名全局项。仅在完整当前集合变化或缺失时agent loop智能体循环会在保留的历史后将其具体化为一份带来源的 user 角色快照。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }``schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 seam 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。
@@ -29,15 +30,18 @@
- `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。
- `PromptSection``{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona工具引导使用 `100199`
- `PromptAssembly``{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输
- `PromptContext``{ name, order, text }`。上下文承载不断变化的当前事实,这些事实不能改写已缓存的系统/历史前缀;上下文与段使用相同的逐组装提供方契约和严格变量契约
- `PromptAssembly``{ sections: AssembledSection[], contexts: AssembledContext[], tools: ToolSchema[], variables: Record<string, string | undefined> }`。段与上下文文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。
- `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}``{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。
- `renderContextSnapshot(assembly)`:对上下文执行同样严格的插值,删除空条目,并发出一份带显式取代声明的完整快照。活动集合为空时返回 `''`;先前可见的上下文消失时,循环会发出一份清除快照。
可通过合并扩展:插件可以借助声明合并,为 `PromptAssembly``AssembleContext` 声明额外字段。
### 扩展点
- 段提供方工具包package拥有跨调用引导`tool:bash``tool:read` 等);此插件拥有 `harness:identity``deployment:persona`
- 变量提供方:agent loop智能体循环注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)
- 上下文提供方:策略及其他变化状态的归属方贡献完整的当前事实,而不改变稳定的系统提示词
- 变量提供方agent loop 注册 `model``cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。
- 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。
- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。
@@ -65,6 +69,20 @@ You are an AI agent powered by the DeepSeek Harness SDK.
只要身份、persona、变量、段文本与顺序的渲染完全相同前缀就保持稳定。任何变更都可能从第一个变化的系统提示词 token 起使复用失效。
### 动态运行时上下文
#### 模型看到的内容
活动上下文经过严格插值后按确定顺序连接,并在首次需要该快照的请求之前立即记录为一条带来源的 user 角色消息。消息以 `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.` 开头。变化后的快照会追加到保留的历史之后;保留的快照未变时不会增加内容。如果压缩移除了它,当前完整快照会再次发出。移除最后一项上下文时会发出一份显式清除快照。
#### Token 影响
首次请求、上下文实际变化、压缩移除保留的快照或活动集合变空时,会增加一条简洁消息。未变化的步骤不会增加重复 token。
#### KV Cache 影响
在保留的历史之后仅追加。上下文变化会保留先前缓存的系统与对话前缀,而不会改写第一条 wire 消息。
### 工具 schema
#### 模型看到的内容

View File

@@ -1,5 +1,5 @@
/**
* Registry for ordered prompt sections, tool schemas, and prompt variables.
* Registry for ordered system sections, cache-safe context, tool schemas, and prompt variables.
*
* @module @deepseek-ai/dsh-system-prompt
*/
@@ -17,7 +17,7 @@ declare module 'cordis' {
interface Events {
/**
* Expert waterfall over the assembled sections, tools, and variables.
* Expert waterfall over the assembled sections, contexts, tools, and variables.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners
* receive only that scope's assemblies. The returned value is authoritative.
* A supplied signal controls only this explicit assembly request and must not
@@ -65,6 +65,20 @@ export interface PromptSection {
readonly text: string | ((context: AssembleContext) => string)
}
/**
* One dynamic model-context contribution. Unlike a {@link PromptSection}, its
* rendered text is materialized as a durable user-role snapshot at the request
* tail, so changing runtime state preserves the stable system/history prefix.
*/
export interface PromptContext {
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.context}). */
readonly name: string
/** Contexts are joined in ascending order, independently of system-section order. */
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
}
/** One section of an assembly: {@link PromptSection} with its text resolved. */
export interface AssembledSection {
/** The contributing section's unique name. */
@@ -73,6 +87,14 @@ export interface AssembledSection {
text: string
}
/** One dynamic context contribution with its text resolved. */
export interface AssembledContext {
/** The contributing context's unique name. */
name: string
/** The resolved (but not yet interpolated) context text. */
text: string
}
/** Tool schemas visible in one assembly and their pre-restriction name set. */
export interface ToolProviderResult {
/** The schemas this provider contributes to THIS assembly. */
@@ -82,11 +104,13 @@ export interface ToolProviderResult {
}
/**
* Merge-extensible assembled prompt. Sections remain uninterpolated until
* {@link renderPrompt}; tools are already in canonical model-facing order.
* Merge-extensible assembled model input. Sections and contexts remain
* uninterpolated until their renderers; tools are already in canonical
* model-facing order.
*/
export interface PromptAssembly {
sections: AssembledSection[]
contexts: AssembledContext[]
tools: ToolSchema[]
variables: Record<string, string | undefined>
}
@@ -170,14 +194,35 @@ export interface Config {
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections
.map(section => interpolate(section, assembly.variables))
.map(section => interpolate(section, assembly.variables, 'section'))
.filter(text => text.length > 0)
.join('\n\n')
}
/** Interpolate one section's `{{variable}}` references (see {@link renderPrompt}). */
function interpolate(section: AssembledSection, variables: Record<string, string | undefined>): string {
const text = section.text
/**
* Render the complete current dynamic context snapshot. The agent loop appends
* a new durable snapshot only when this text changes or is no longer retained
* after compaction; the explicit supersession clause makes older snapshots in
* history harmless.
* @param assembly - the assembly whose contexts and variables to render.
* @returns the current full snapshot, or `''` when no context is active.
*/
export function renderContextSnapshot(assembly: PromptAssembly): string {
const body = assembly.contexts
.map(context => interpolate(context, assembly.variables, 'context'))
.filter(text => text.length > 0)
.join('\n\n')
if (body.length === 0) return ''
return `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n${body}`
}
/** Interpolate one section or context and attribute diagnostics to its owning input. */
function interpolate(
input: AssembledSection | AssembledContext,
variables: Record<string, string | undefined>,
kind: 'section' | 'context',
): string {
const text = input.text
let result = ''
let last = 0
for (let open = text.indexOf('{{'); open >= 0; open = text.indexOf('{{', last)) {
@@ -185,7 +230,7 @@ function interpolate(section: AssembledSection, variables: Record<string, string
if (group === null) {
// A later closing brace makes this malformed; otherwise it is literal prose.
if (text.indexOf('}}', open + 2) >= 0) {
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`)
throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in ${kind} "${input.name}" (references are complete simple {{name}} groups)`)
}
result += text.slice(last, open + 2)
last = open + 2
@@ -194,16 +239,16 @@ function interpolate(section: AssembledSection, variables: Record<string, string
// `{{}}` yields an empty name and follows the malformed-reference path.
const name = group[0].slice(2, -2)
if (!VARIABLE_NAME.test(name)) {
throw new Error(`malformed prompt variable reference "{{${name}}}" in section "${section.name}" (variable names match ${String(VARIABLE_NAME)})`)
throw new Error(`malformed prompt variable reference "{{${name}}}" in ${kind} "${input.name}" (variable names match ${String(VARIABLE_NAME)})`)
}
// Do not resolve unregistered names through Object.prototype.
if (!Object.hasOwn(variables, name)) {
const known = Object.keys(variables)
throw new Error(`unknown prompt variable "{{${name}}}" in section "${section.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
throw new Error(`unknown prompt variable "{{${name}}}" in ${kind} "${input.name}"; registered variables: ${known.length > 0 ? known.join(', ') : '(none)'}`)
}
const value = variables[name]
if (value === undefined) {
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (section "${section.name}")`)
throw new Error(`prompt variable "{{${name}}}" has no value for this assembly (${kind} "${input.name}")`)
}
result += text.slice(last, open) + value
last = open + group[0].length
@@ -220,6 +265,7 @@ type VariableProvider = (context: AssembleContext) => string | undefined
/** All prompt registrations owned by one global or scoped layer. */
class PromptLayer implements ScopeLayer {
readonly sections: NamedEntries<PromptSection>
readonly contexts: NamedEntries<PromptContext>
readonly toolProviders = new AnonymousEntries<ToolProvider>()
readonly variables: NamedEntries<VariableProvider>
@@ -231,6 +277,9 @@ class PromptLayer implements ScopeLayer {
this.sections = new NamedEntries(name => new Error(scope === undefined
? `prompt section "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt section "${name}" is already registered in this scope`))
this.contexts = new NamedEntries(name => new Error(scope === undefined
? `prompt context "${name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
: `prompt context "${name}" is already registered in this scope`))
this.variables = new NamedEntries(name => new Error(scope === undefined
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
: `prompt variable "${name}" is already registered in this scope`))
@@ -239,6 +288,7 @@ class PromptLayer implements ScopeLayer {
/** @returns whether this layer owns no prompt registrations. */
isEmpty(): boolean {
return this.sections.isEmpty()
&& this.contexts.isEmpty()
&& this.toolProviders.isEmpty()
&& this.variables.isEmpty()
}
@@ -297,6 +347,25 @@ export class SystemPrompt extends Service {
)
}
/**
* Register ordered cache-safe dynamic context in the calling context's scope.
* A scoped context shadows a global context with the same name; duplicates
* within one layer and non-finite orders throw. Registration and disposal
* emit `system-prompt/change`.
* @param context - the context contribution to register.
* @returns the exact Cordis effect disposer.
*/
context(context: PromptContext): () => void {
if (!Number.isFinite(context.order)) {
throw new TypeError(`prompt context "${context.name}" order must be a finite number`)
}
return this.layers.effect(
this.ctx,
layer => layer.contexts.insert(context.name, context),
{ label: 'systemPrompt.context()' },
)
}
/**
* Register a tool-schema provider in the calling context's scope. Global and
* matching scoped providers both contribute; returning the reserved
@@ -352,6 +421,7 @@ export class SystemPrompt extends Service {
}
// Scoped sections shadow globals before the stable order sort.
const sectionByName = this.layers.merge(scope, layer => layer.sections)
const contextByName = this.layers.merge(scope, layer => layer.contexts)
// Validate order against pre-restriction names while collecting visible schemas.
const providers = [
...this.layers.global.toolProviders.values(),
@@ -377,6 +447,12 @@ export class SystemPrompt extends Service {
name: section.name,
text: typeof section.text === 'function' ? section.text(context) : section.text,
})),
contexts: [...contextByName.values()]
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}

View File

@@ -22,6 +22,14 @@ function validateAssembly(assembly: PromptAssembly, fail: InvariantFailure): voi
if (typeof section.text !== 'string') fail(`assembled section ${JSON.stringify(section.name)} text must be a string`)
}
const contextNames = new Set<string>()
for (const context of assembly.contexts) {
if (context.name.length === 0) fail('assembled context names must be non-empty')
if (contextNames.has(context.name)) fail(`assembled context name ${JSON.stringify(context.name)} is duplicated`)
contextNames.add(context.name)
if (typeof context.text !== 'string') fail(`assembled context ${JSON.stringify(context.name)} text must be a string`)
}
for (const tool of assembly.tools) {
if (tool.name.length === 0) fail('assembled tool names must be non-empty')
}

View File

@@ -13,6 +13,7 @@ async function setup(): Promise<Context> {
const valid = (): PromptAssembly => ({
sections: [{ name: 'identity', text: 'prompt' }],
contexts: [{ name: 'policy', text: 'current policy' }],
tools: [{ name: 'echo', description: 'Echo', parameters: {} }],
variables: { cwd: '/repo', optional: undefined },
})
@@ -34,6 +35,9 @@ describe('system-prompt invariants', () => {
[{ ...valid(), sections: [{ name: '', text: 'x' }] }, /section names must be non-empty/],
[{ ...valid(), sections: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /section name "x" is duplicated/],
[{ ...valid(), sections: [{ name: 'x', text: 1 as never }] }, /section "x" text must be a string/],
[{ ...valid(), contexts: [{ name: '', text: 'x' }] }, /context names must be non-empty/],
[{ ...valid(), contexts: [{ name: 'x', text: 'a' }, { name: 'x', text: 'b' }] }, /context name "x" is duplicated/],
[{ ...valid(), contexts: [{ name: 'x', text: 1 as never }] }, /context "x" text must be a string/],
[{ ...valid(), tools: [{ name: '', description: 'x', parameters: {} }] }, /tool names must be non-empty/],
[{ ...valid(), variables: { Bad: 'x' } }, /variable name "Bad" is invalid/],
[{ ...valid(), variables: { value: 1 as never } }, /variable "value" must be a string or undefined/],

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
async function mount(config: Config = {}): Promise<Context> {
@@ -125,6 +125,25 @@ describe('scoped variables', () => {
})
})
describe('scoped cache-safe context', () => {
it('shadows a global context for one scope and cleans up with that scope', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child-context')
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'global policy' })
scope.ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'scoped policy' })
expect(() => scope.ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'duplicate' }))
.toThrow('prompt context "policy" is already registered in this scope')
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain('scoped policy')
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toContain('global policy')
await scope.dispose()
expect(renderContextSnapshot(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })))
.toContain('global policy')
})
})
describe('scoped tool providers and toolOrder × restriction', () => {
it('scoped providers are consulted only for their scope', async () => {
const ctx = await mount()

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SystemPrompt, { AssembleContext, PromptAssembly, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
/**
* Every assembly carries the plugin's own built-ins — `harness:identity`
@@ -64,14 +64,21 @@ describe('SystemPrompt', () => {
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
ctx.systemPrompt.context({ name: 'later', order: 20, text: () => 'context 2' })
ctx.systemPrompt.context({ name: 'earlier', order: 10, text: 'context 1' })
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
expect(assembly.sections.map(s => s.text)).toEqual([IDENTITY, 'You are DeepSeek Harness SDK.', 'Be precise.', 'cwd: /tmp'])
expect(assembly.contexts).toEqual([
{ name: 'earlier', text: 'context 1' },
{ name: 'later', text: 'context 2' },
])
expect(assembly.tools).toEqual([{ name: 'echo', description: 'echo back', parameters: {} }])
expect(assembly.variables).toEqual({})
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.\n\nBe precise.\n\ncwd: /tmp`)
expect(renderContextSnapshot(assembly)).toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncontext 1\n\ncontext 2')
})
it('resolves section text providers against the assemble context, at each assemble call', async () => {
@@ -96,16 +103,19 @@ describe('SystemPrompt', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
inner.systemPrompt.context({ name: 'scoped-context', order: 0, text: 'scoped context' })
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
inner.systemPrompt.variable('scoped_var', () => 'v')
}, { inject: ['systemPrompt'] }))
const before = await ctx.systemPrompt.assemble()
expect(contributed(before)).toHaveLength(1)
expect(before.contexts).toHaveLength(1)
expect(before.variables).toEqual({ scoped_var: 'v' })
await fiber.dispose()
const assembly = await ctx.systemPrompt.assemble()
expect(contributed(assembly)).toHaveLength(0)
expect(assembly.contexts).toHaveLength(0)
// The built-ins belong to the service fiber, so they survive the plugin's disposal.
expect(assembly.sections.map(s => s.name)).toEqual(BUILT_IN)
expect(assembly.tools).toHaveLength(0)
@@ -131,6 +141,17 @@ describe('SystemPrompt', () => {
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
})
it('rejects duplicate and non-finite context registrations without leaking', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'first' })
expect(() => ctx.systemPrompt.context({ name: 'policy', order: 2, text: 'second' }))
.toThrow('prompt context "policy" is already registered')
expect(() => ctx.systemPrompt.context({ name: 'bad', order: Number.NaN, text: 'x' }))
.toThrow('prompt context "bad" order must be a finite number')
expect((await ctx.systemPrompt.assemble()).contexts).toEqual([{ name: 'policy', text: 'first' }])
})
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -236,7 +257,7 @@ describe('SystemPrompt', () => {
ctx.systemPrompt.section({ name: 'real', order: 0, text: 'real' })
ctx.on('system-prompt/assemble', async () => {
return { sections: [], tools: [], variables: {} } satisfies PromptAssembly
return { sections: [], contexts: [], tools: [], variables: {} } satisfies PromptAssembly
})
const assembly = await ctx.systemPrompt.assemble()
@@ -252,6 +273,7 @@ describe('SystemPrompt', () => {
const first = await ctx.systemPrompt.assemble()
first.sections[0]!.name = 'mutated'
first.sections[0]!.text = 'mutated'
first.contexts.push({ name: 'mutated', text: 'mutated' })
first.tools[0]!.description = 'mutated'
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
firstParameters.properties['leak'] = { type: 'string' }
@@ -259,6 +281,7 @@ describe('SystemPrompt', () => {
const second = await ctx.systemPrompt.assemble()
expect(second.sections.map(section => section.name)).toEqual(['harness:identity', 'deployment:persona', 'base'])
expect(second.sections[0]!.text).toBe(IDENTITY)
expect(second.contexts).toEqual([])
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
})
@@ -268,12 +291,33 @@ describe('SystemPrompt', () => {
{ name: 'empty', text: '' },
{ name: 'real', text: 'content' },
],
contexts: [],
tools: [],
variables: {},
})
expect(result).toBe('content')
})
it('filters empty context, interpolates variables, and returns empty without active context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.context({ name: 'empty', order: 0, text: '' })
expect(renderContextSnapshot(await ctx.systemPrompt.assemble())).toBe('')
ctx.systemPrompt.variable('mode', () => 'read-only')
ctx.systemPrompt.context({ name: 'policy', order: 1, text: 'Mode: {{mode}}.' })
expect(renderContextSnapshot(await ctx.systemPrompt.assemble()))
.toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nMode: read-only.')
})
it('attributes context interpolation failures to the contributing context', () => {
expect(() => renderContextSnapshot({
sections: [],
contexts: [{ name: 'policy', text: 'Mode: {{missing}}.' }],
tools: [],
variables: {},
})).toThrow('unknown prompt variable "{{missing}}" in context "policy"; registered variables: (none)')
})
it('emits system-prompt/change when a tool provider is registered and disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -290,6 +334,17 @@ describe('SystemPrompt', () => {
expect(changeCount).toBe(2)
})
it('emits system-prompt/change when a context is registered and disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
let changeCount = 0
ctx.on('system-prompt/change', () => void changeCount++)
const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'current' })
expect(changeCount).toBe(1)
dispose()
expect(changeCount).toBe(2)
})
it('cleans up tool providers on fiber dispose', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -404,13 +459,14 @@ describe('SystemPrompt', () => {
})
it('names "(none)" when no variables are registered at all', () => {
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], contexts: [], tools: [], variables: {} }))
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
})
it('throws when a referenced variable has no value for this assembly', () => {
expect(() => renderPrompt({
sections: [{ name: 'persona', text: 'in {{cwd}}' }],
contexts: [],
tools: [],
variables: { cwd: undefined },
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
@@ -419,6 +475,7 @@ describe('SystemPrompt', () => {
it('throws on a malformed complete reference, e.g. inner spaces', () => {
expect(() => renderPrompt({
sections: [{ name: 's', text: 'on {{ model }}' }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
@@ -427,6 +484,7 @@ describe('SystemPrompt', () => {
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
const text = renderPrompt({
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
contexts: [],
tools: [],
variables: {},
})
@@ -439,6 +497,7 @@ describe('SystemPrompt', () => {
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
expect(() => renderPrompt({
sections: [{ name: 's', text }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference at')
@@ -449,6 +508,7 @@ describe('SystemPrompt', () => {
// source into the prompt; Object.hasOwn must reject it instead.
expect(() => renderPrompt({
sections: [{ name: 's', text: 'on {{constructor}}' }],
contexts: [],
tools: [],
variables: { model: 'm' },
})).toThrow('unknown prompt variable "{{constructor}}"')
@@ -465,6 +525,7 @@ describe('SystemPrompt', () => {
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
const text = renderPrompt({
sections: [{ name: 's', text: 'v = {{model}}!' }],
contexts: [],
tools: [],
variables: { model: 'literal {{sneaky}} inside' },
})

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/fs/fs-sandbox/README.md
README.md: 790444a4184b9bcccd3a0798cf0c09cb6f1b166e
README.zh.md: d54bdcbe65673b6892ebd1d539dd066f66d68cb6
README.md: d6070f4971e7531e929e659b6b5ed476a672dc5d
README.zh.md: c051b240163f229f7fbc583939c9f4da5095e9aa

View File

@@ -22,11 +22,19 @@ A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
### Filesystem policy and refusals
#### What the model sees
The policy owner contributes capability-neutral `sandbox:policy` context. Indirectly, `dsh-tool-fs` renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
#### Token effect
The current-policy clause adds a small runtime-context message while this backend is mounted; a denial adds the bounded marker and escalation hint to conversation history.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; operation results remain append-only.
## Known Limitations and Deferred Work

View File

@@ -22,11 +22,19 @@
## 模型体验
通过 `dsh-tool-fs` 间接产生影响;该消费方把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。
### 文件系统策略与拒绝
#### 模型看到的内容
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。作为间接影响,`dsh-tool-fs` 会把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。
#### Token 影响
该后端挂载期间,当前策略条款会增加一条简短的运行时上下文消息;拒绝则会把有界标记和升级提示追加到对话历史。
#### KV Cache 影响
不会直接使缓存失效;上述消费方负责请求前缀的任何变化
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;操作结果保持仅追加
## 已知限制与暂缓事项

View File

@@ -44,7 +44,7 @@ import { isPathUnder } from './containment.ts'
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
* base for relative paths). The sandbox default (mode + `workspace-write`
* fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
* session for both enforcing families.
* session for every enforcing capability.
*/
export type Config = LocalConfig

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/fs/tool-fs/README.md
README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239
README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6
README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5
README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **model-facing filesystem tools**`read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
The **model-facing filesystem tools**`read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
```ts ignore-check
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
@@ -46,6 +46,8 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_permissions` and `justification` and resolve approved retries through `ctx.approval`. The policy owner contributes capability-neutral standing policy; the tool results retain operation-specific denial and retry guidance.
## `fs/observed` is fire-and-forget
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
**面向模型的文件系统工具**`read``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`**不**注入策略服务。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。
**面向模型的文件系统工具**`read``write``edit`)及其**执行器**。这是文件系统栈的消费方层拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam[`@deepseek-ai/dsh-fs`](../fs))读取写入编辑。新鲜度观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。
```ts ignore-check
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
@@ -46,6 +46,8 @@ await ctx.plugin(ToolFs) // this package — re
工具在每次分派中把 `exec`(工具执行上下文)作为不透明 `actor` 传入。默认 thunk 返回 `undefined`(不受约束的裸提供方)。加载 `@deepseek-ai/dsh-fs-policy` 后,它会占用单个决策槽:返回 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`,并在 `fs/observed` 时记录。后端错误(`FsError`)和抛出的 `FS_NOT_OBSERVED` 会流经 `ToolRegistry.execute()`,变成 `isError` 工具结果,并附带 `{ name, code }`。
当 `ctx.fs.sandboxMode` 表明提供方施加沙箱限制时write/edit 会公开 `sandbox_permissions` 与 `justification`,并通过 `ctx.approval` 解析经批准的重试。策略归属方会贡献与具体能力无关的常驻策略;工具结果仍保留操作特定的拒绝与重试引导。
## `fs/observed` 发后即忘
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。

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/pty/pty-local/README.md
README.md: 6f243a6edf3ab8bc228cfda3f6b3b774dabadcbb
README.zh.md: 0cf2981332fa41dab22a496de5ae302066fbdba8
README.md: ba05495318127b63b3d2a6a60ec743e1ff1c5821
README.zh.md: 81987ea0685d761507b535b7ed6eefa0888fbd54

View File

@@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo
## Plugin (`pty-local`)
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
@@ -14,19 +14,19 @@ Send cancellation resolves the current foreground process group and delivers a r
## Model Experience
### Indirect consumer
### Current file policy and indirect consumer
#### What the model sees
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-pty` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
#### Token effect
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
#### KV Cache effect
No direct invalidation; the consumer owns prompts, schemas, and appended results.
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
## Known Limitations and Deferred Work

View File

@@ -6,7 +6,7 @@
## 插件(`pty-local`
该插件注入 `pty``sandbox``sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
该插件注入 `pty``sandbox``sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。Spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell并以调用方提供的确切中止原因拒绝即使当时还无法观察其前台进程组。如果关闭失败`PtyBackendCleanupError` 会单独保留清理失败,供注册表 dispose资源释放时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
@@ -14,19 +14,19 @@ Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash
## 模型体验
### 间接消费方
### 当前文件策略与间接消费方
#### 模型看到的内容
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-pty` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
#### Token 影响
消费方返回有界的后端输出前没有影响。此包package不会把保留的 PTY scrollback 入模型历史。
装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
#### KV Cache 影响
不会直接使 KV Cache 失效提示词、schema 与追加结果由消费方负责
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加
## 已知限制与暂缓事项

View File

@@ -12,7 +12,7 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { createProcessInspector } from './process-inspector.ts'
@@ -71,14 +71,11 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
}
}
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
const argv = [config.shellPath, ...config.shellArgs]
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
if (mode === 'danger-full-access') return argv
return ctx.sandbox.confine(argv, {
mode: mode,
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
}).argv
if (policy.mode === 'danger-full-access') return argv
// Re-state the discriminant because object spread does not preserve its narrowed type.
return ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
}
/** Local shell backend registered under the configured type. */
@@ -102,14 +99,15 @@ export class LocalPtyBackend implements PtyBackend {
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
spec.signal?.throwIfAborted()
ensureSandboxModeFence(this.ctx, spec.owner)
const argv = spawnArgv(this.ctx, this.config, spec)
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
const argv = spawnArgv(this.ctx, this.config, policy)
const file = argv[0]
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
const options: IPtyForkOptions = {
name: 'dumb',
cols: this.config.cols,
rows: this.config.rows,
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
cwd: spec.cwd ?? policy.workspaceRoot,
env: childEnvironment(spec),
}
const terminal = this.spawnTerminal(file, argv.slice(1), options)

View File

@@ -38,10 +38,13 @@ function config(): ResolvedConfig {
}
}
function agent(ctx: Context): Agent {
function agent(ctx: Context, cwd?: string): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
id,
options: {},
session: new Session(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } }),
status: 'idle', acceptsNextStep: false, ctx,
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(),
}
}
@@ -125,10 +128,10 @@ describe('LocalPtyBackend startup rollback', () => {
} satisfies Partial<PtyBackendCleanupError>))
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
it('resolves session mode and root together before wrapping the shell', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
const terminal = {} as IPty
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
@@ -146,8 +149,10 @@ describe('LocalPtyBackend startup rollback', () => {
)
const previous = process.env.PTY_TEST_SECRET
process.env.PTY_TEST_SECRET = 'must-not-leak'
const owner = agent(ctx, '/session-workspace')
setSandboxMode(owner.session, 'workspace-write')
try {
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
expect(await backend.spawn(spec(owner))).toBe(session)
} finally {
if (previous === undefined) delete process.env.PTY_TEST_SECRET
else process.env.PTY_TEST_SECRET = previous
@@ -157,7 +162,7 @@ describe('LocalPtyBackend startup rollback', () => {
file: '/sandbox',
args: ['--', '/bin/bash', '-i'],
options: {
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
name: 'dumb', cols: 80, rows: 24, cwd: '/session-workspace',
env: {
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
@@ -166,6 +171,10 @@ describe('LocalPtyBackend startup rollback', () => {
})
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
}])
})
it('composes the default local session around a spawned terminal', async () => {

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/sandbox/sandbox-policy/README.md
README.md: dca54330bc888af9ecac21aa92019d8a2b0140bd
README.zh.md: a201d48c81f563fc3d85495e964bb67432517a3c
README.md: b2512790c5cf5b3a06523cf91b50dd5d288b1522
README.zh.md: dda98b7aa4775ac2453704295df87c3c92025093

View File

@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability family receives one resolved mode-and-root policy per call.
The single owner of sandbox-policy resolution: the deployment's default [`SandboxMode`](../sandbox/README.md) and fallback root, plus each session's durable mode override and immutable workspace root. Every enforcing capability receives one resolved mode-and-root policy per call; before each request, the model receives the current policy without a separate capability inventory.
## Why a shared home
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each resolved its own `mode` + `workspaceRoot`, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both tool layers resolve policy through `ctx.sandboxPolicy`, and both enforcing backends consume that complete per-call result. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision.
Filesystem tools, one-shot bash commands, and terminal sessions may enforce the same mode vocabulary in different combinations. If each resolved its own `mode` + `workspaceRoot`, they could drift into a split world, exactly what [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Each enforcing backend consumes the complete owner-resolved policy, while the current context describes only what that policy means for any available operation the DSH file sandbox enforces. The [cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the shared-policy decision.
## Config
@@ -17,25 +17,53 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
- `sandbox:policy` — a request-time cache-safe context contribution derived directly from `resolve({ session })`. It states the mode's capability-neutral file-effect contract and the canonical session workspace under `workspace-write`; tool owners retain operation-specific denial and escalation guidance.
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules.
The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and core execution-enclosure rules. The agent loop logs the assembled full runtime-context snapshot as a sourced `user/message`, so exact policy input remains reconstructable without an in-memory “last told” mirror.
## The per-session store
A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event.
A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot.
## Model Experience
Indirectly, through `dsh-tool-bash` and `dsh-tool-fs`, which render the effective mode this service holds in their `[sandbox: …]` denial markers and escalation prompts; the `sandbox/mode` event itself never reaches the model.
### Current file sandbox policy
#### What the model sees
One `sandbox:policy` contribution in the current runtime-context snapshot for every agent session. It does not enumerate mounted capabilities. Tool plugins retain operation and escalation guidance, approval policy contributes separately to the same snapshot, and plan guidance remains `dsh-plan-mode`'s system section.
##### Read-only
```markdown
Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.
```
##### Workspace-write
```markdown
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Some platform temporary areas may also be writable.
```
##### Danger-full-access
```markdown
Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.
```
#### Token effect
One concise durable context message on the first request and each effective policy change; unchanged requests add nothing. `workspace-write` carries only the canonical session workspace path; platform-specific temporary paths are summarized without adding host-dependent bytes.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes, and the mode is deliberately absent from the prompt.
The stable system prompt remains byte-identical across mode changes. A changed full context snapshot is appended after retained history, preserving the prior cached prefix; subsequent unchanged requests reuse that retained snapshot.
## Known Limitations and Deferred Work
- **One primary workspace root per session** — policy resolves `SessionHeader.cwd`; extra writable roots are not part of `SandboxExecutionPolicy`.
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.
- **Temporary areas are deliberately summarized** — enforcing backends grant different platform temporary areas, which are selected after policy resolution and therefore cannot be enumerated truthfully in the current context.

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每强制执行策略的能力家族在每次调用时都会收到一项解析完成的模式与根目录策略。
沙箱策略解析的唯一归属位置:部署默认 [`SandboxMode`](../sandbox/README.md) 与回退根目录,加上每个会话的持久模式覆盖和不可变工作区根目录。每项负责强制执行的能力在每次调用时都会收到一项解析完成的模式与根目录策略;模型在每次请求前会收到当前策略,而不会另收一份能力清单
## 为何需要共享归属位置
两个家族强制执行同一套模式词汇:沙箱化 bash 执行器(`@deepseek-ai/dsh-bash-sandbox`)与沙箱化文件系统提供方(`@deepseek-ai/dsh-fs-sandbox`。如果两者各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界bash 限制在一个根目录fs 却隔离另一个根目录,正是[沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。两个工具层都通过 `ctx.sandboxPolicy` 解析策略,两个强制执行后端也都消费完整的逐调用结果。[跨家族 fs 沙箱 RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。
文件系统工具、一次性 bash 命令和终端会话可以用不同组合强制执行同一套模式词汇。如果各自解析 `mode` + `workspaceRoot`,就可能漂移成分裂世界,正是[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)所警告的情况。每个强制执行后端都会消费归属方解析出的完整策略,而当前上下文只说明该策略对于任何受 DSH 文件沙箱强制执行的可用操作有何含义。[跨家族 fs 沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)记录了共享策略决策。
## 配置
@@ -17,25 +17,53 @@
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。
- `ctx.sandboxPolicy.defaultMode``ctx.sandboxPolicy.workspaceRoot``resolve()` 使用的部署默认值与回退根目录。
- `sandbox:policy`:直接派生自 `resolve({ session })` 的请求时缓存安全上下文贡献。它说明该模式中与具体能力无关的文件效果契约,以及 `workspace-write` 下规范化的会话工作区;工具归属方仍负责操作特定的拒绝与升权引导。
- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold最后一次切换胜出没有则为 `undefined`),在 `resolve()` 内使用。
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件只要其值不在该封闭词汇中Session 与其配套组件负责相关存储与核心执行封闭规则。
可选的 `./invariant` 配套组件会拒绝伪造的持久 `sandbox/mode` 事件只要其值不在该封闭词汇中Session 与其配套组件负责相关存储与核心执行封闭规则。agent loop智能体循环会将组装后的完整运行时上下文快照记录为一条带来源的 `user/message`,因此无需内存中的「上次告知」镜像,也能重建确切的策略输入。
## 逐会话存储
运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志(沿用 `approval/*` 先例):模型通过强制执行工具的拒绝标记获知模式,绝不会从事件获知
运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照
## 模型体验
通过 `dsh-tool-bash``dsh-tool-fs` 间接影响;它们会在 `[sandbox: …]` 拒绝标记和升权提示词中渲染该服务持有的有效模式,`sandbox/mode` 事件本身绝不会到达模型。
### 当前文件沙箱策略
#### 模型看到的内容
每个 agent 会话的当前运行时上下文快照中都有一项 `sandbox:policy` 贡献。它不枚举已装载的能力。工具插件继续负责操作与升级引导,批准策略单独贡献给同一份快照,计划引导仍由 `dsh-plan-mode` 的系统段落管理。
##### 只读
```markdown
Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.
```
##### 工作区写入
```markdown
Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: "<workspace root>". Some platform temporary areas may also be writable.
```
##### 完全访问
```markdown
Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.
```
#### Token 影响
首次请求和有效策略每次变化时增加一条简洁的持久上下文消息;未变化的请求不增加内容。`workspace-write` 只携带规范化的会话工作区路径;平台特定的临时路径会以摘要表述,不会加入依赖主机的字节。
#### KV Cache 影响
不会直接使 KV Cache 失效;请求前缀变更由上述消费方负责,且提示词有意不包含模式
模式切换时,稳定的系统提示词仍逐字节相同。变化后的完整上下文快照会追加到保留的历史之后,从而保留此前已缓存的前缀;后续未变化的请求会复用该保留快照
## 已知限制与暂缓事项
- **每个会话只有一个主要工作区根目录**:策略解析 `SessionHeader.cwd`;额外可写根目录不属于 `SandboxExecutionPolicy`
- **仅限文件操作模式**`SandboxMode` 管控文件操作;网络和进程策略不在其词汇中,因此这里没有限制它们的旋钮。
- **有意概述临时区域**:强制执行后端会授予不同的平台临时区域,这些区域在策略解析后才会选定,因此无法在当前上下文中如实枚举。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-sandbox-policy",
"description": "Per-call sandbox policy resolver (ctx.sandboxPolicy): deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family",
"description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -27,18 +27,22 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -3,13 +3,17 @@
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
* {@link SandboxMode}, the `workspace-write` root, and the override kit (the
* `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
* Before each agent request, the owner also contributes the resolved policy to
* the cache-safe runtime-context snapshot. The agent loop logs that snapshot as
* model history, so replay reconstructs the same mode and root the enforcing
* consumers resolve without rewriting the stable system prompt.
*
* Both enforcing capability families read the SAME policy here: the sandboxed
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
* provider (`@deepseek-ai/dsh-fs-sandbox`) consume the SAME resolved per-call
* policy, so bash and fs can never confine to different roots — the split
* world the sandbox RFC warns about. The service reads session state once at
* the tool boundary; executors and providers remain session-free.
* Enforcing filesystem, one-shot bash, and terminal backends read the SAME
* resolved policy here. The context describes that policy without inventorying
* capabilities, while each backend retains its own enforcement dialect and each
* tool owns its operation-specific denial and escalation guidance. The service
* reads session state once at each operation boundary; executors and providers
* remain session-free.
*
* @module @deepseek-ai/dsh-sandbox-policy
*/
@@ -17,8 +21,10 @@
import { resolve as resolvePath } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { effectiveSandboxMode } from './session-mode.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
@@ -28,6 +34,23 @@ function resolveWorkspaceRoot(path: string): string {
return resolvePath(canonicalPath(path))
}
/** Render the policy without claiming which capabilities are mounted. */
function renderPolicyContext(policy: SandboxExecutionPolicy): string {
switch (policy.mode) {
case 'read-only':
return 'Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.'
case 'workspace-write':
return `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(policy.workspaceRoot)}. Some platform temporary areas may also be writable.`
case 'danger-full-access':
return 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.'
/* v8 ignore next 4 -- SandboxMode is a typed same-process closed union; this branch is only the static exhaustiveness guard. */
default: {
const mode: never = policy.mode
throw new Error(`unreachable sandbox mode: ${String(mode)}`)
}
}
}
declare module 'cordis' {
interface Context {
sandboxPolicy: SandboxPolicyService
@@ -61,9 +84,9 @@ export interface SandboxPolicyRequest {
/**
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
* default mode and fallback workspace root. Tool layers call {@link resolve}
* for each execution so a session's mode log and immutable cwd travel together
* to every enforcing capability.
* default mode, fallback workspace root, and current request-time policy
* section. Tool layers call {@link resolve} for each execution so a session's
* mode log and immutable cwd travel together to every enforcing capability.
*/
export class SandboxPolicyService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
@@ -78,7 +101,6 @@ export class SandboxPolicyService extends Service {
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
// schemastery (static Config) already filled `mode`; the cast records that
@@ -86,6 +108,19 @@ export class SandboxPolicyService extends Service {
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.context({
name: 'sandbox:policy',
order: 110,
text: (context) => {
const session = context.agent?.session
return session === undefined
? ''
: renderPolicyContext(this.resolve({ session }))
},
})
})
}
/**

View File

@@ -5,11 +5,11 @@
* `effective = fold(events) ?? the deployment default`, so an override
* survives restart by replay, two sessions can never see each other's state,
* and there is no external config store. The event is log-only (the
* `approval/*` precedent): the model learns the mode from the boundary
* markers in the enforcing tools, never from the event itself. EXECUTION
* honors the fold through `ctx.sandboxPolicy.resolve()` — it stamps the mode
* together with the calling session's workspace root onto each capability
* call, weakest-precedence beneath an escalation grant.
* `approval/*` precedent): the policy owner projects the fold into each model
* request, while enforcing tools report operation-specific boundary markers.
* EXECUTION honors the same fold through `ctx.sandboxPolicy.resolve()` — it
* stamps the mode together with the calling session's workspace root onto each
* capability call, weakest-precedence beneath an escalation grant.
*
* The override is policy state shared by every enforcing family (bash and
* filesystem alike), so it lives here in the policy package rather than in any

View File

@@ -1,7 +1,7 @@
/**
* Tests for the sandbox-policy home: the deployment default (mode +
* workspaceRoot) the service exposes, and the per-session `sandbox/mode`
* override kit (fold + write path) both enforcing families read.
* override kit (fold + write path) every enforcing capability reads.
*/
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
@@ -9,8 +9,10 @@ import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import SystemPrompt, { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
const ctx = new Context()
@@ -28,6 +30,15 @@ function session(id: string, cwd?: string): Session {
})
}
function agentFor(activeSession: Session): Agent {
return { session: activeSession } as unknown as Agent
}
async function policyContext(ctx: Context, activeSession: Session): Promise<string | undefined> {
return (await ctx.systemPrompt.assemble({ agent: agentFor(activeSession) }))
.contexts.find(context => context.name === 'sandbox:policy')?.text
}
describe('SandboxPolicyService', () => {
it('defaults to read-only under the process cwd', async () => {
const ctx = await mounted()
@@ -114,12 +125,81 @@ describe('SandboxPolicyService', () => {
await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow()
})
it('unregisters cleanly from a child fiber (HMR safety)', async () => {
it('disposes the service and context contribution from a child fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(SandboxPolicyService, {})
expect(ctx.sandboxPolicy).toBeDefined()
expect(await policyContext(ctx, session('sess-hmr'))).toContain('read-only')
await fiber.dispose()
expect(ctx.get('sandboxPolicy')).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).contexts.find(context => context.name === 'sandbox:policy')).toBeUndefined()
})
})
describe('sandbox:policy request context', () => {
async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(SandboxPolicyService, config)
return ctx
}
it.each(['read-only', 'workspace-write', 'danger-full-access'] as const)('renders the exact %s policy without a capability inventory', async (mode) => {
const ctx = await promptMounted({ mode, workspaceRoot: '/fallback' })
const workspaceRoot = resolve('/projects/current')
const expected = {
'read-only': 'Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.',
'workspace-write': `Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(workspaceRoot)}. Some platform temporary areas may also be writable.`,
'danger-full-access': 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.',
} as const
expect(await policyContext(ctx, session(`sess-${mode}`, '/projects/../projects/current'))).toBe(expected[mode])
})
it('keeps the complete rendered prompt byte-stable across TMPDIR changes', async () => {
const ctx = await promptMounted({ mode: 'workspace-write' })
const active = session('sess-tmpdir-stability', '/projects/current')
const previous = process.env.TMPDIR
try {
process.env.TMPDIR = '/tmp/first-host-temp'
const firstAssembly = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
const firstPrompt = renderPrompt(firstAssembly)
const firstContext = renderContextSnapshot(firstAssembly)
process.env.TMPDIR = '/tmp/second-host-temp'
const secondAssembly = await ctx.systemPrompt.assemble({ agent: agentFor(active) })
expect(renderPrompt(secondAssembly)).toBe(firstPrompt)
expect(renderContextSnapshot(secondAssembly)).toBe(firstContext)
expect(firstContext).not.toContain('host-temp')
} finally {
if (previous === undefined) delete process.env.TMPDIR
else process.env.TMPDIR = previous
}
})
it('reflects the latest durable switch on the next assembly and stays byte-stable otherwise', async () => {
const ctx = await promptMounted()
const active = session('sess-switch', '/projects/current')
const first = await policyContext(ctx, active)
expect(await policyContext(ctx, active)).toBe(first)
setSandboxMode(active, 'danger-full-access')
const danger = await policyContext(ctx, active)
expect(danger).toBe('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
expect(await policyContext(ctx, active)).toBe(danger)
setSandboxMode(active, 'workspace-write')
expect(await policyContext(ctx, active)).toBe(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(resolve('/projects/current'))}. Some platform temporary areas may also be writable.`)
})
it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => {
const active = session('sess-resume', '/projects/current')
setSandboxMode(active, 'workspace-write')
const resumed = new Session(active.id, active.events, active.header)
const ctx = await promptMounted({ mode: 'read-only' })
expect(await policyContext(ctx, resumed)).toContain('workspace-write')
expect((await ctx.systemPrompt.assemble()).contexts.find(context => context.name === 'sandbox:policy')?.text).toBe('')
})
})

View File

@@ -17,12 +17,18 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../sandbox"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
}

View File

@@ -100,7 +100,20 @@ describe('in-process policy inheritance', () => {
const request = child.session.events.find(
(event): event is SessionEvent<'request/header'> => event.type === 'request/header',
)
expect(request?.data.header.system).toContain('Approval prompts are disabled')
const runtimeContext = child.session.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === '@deepseek-ai/dsh-system-prompt',
)
if (request === undefined || runtimeContext === undefined) throw new Error('child request lacks its runtime policy context')
expect(runtimeContext.seq).toBeLessThan(request.seq)
const contextText = runtimeContext.data.content
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
expect(contextText).toContain('Current DSH file policy: read-only')
expect(contextText).toContain('Approval prompts are disabled')
expect(request.data.header.system).not.toContain('Approval prompts are disabled')
expect(parent.session.events).toHaveLength(parentLogLength)
} finally {
await run.dispose()

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/ui/user-approval/README.md
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc
README.md: 7b87a75d1c7c43874c484bc11f8deed45cb523ce
README.zh.md: c15871073231b6e97f37fc0338f4824025ba86ca

View File

@@ -8,38 +8,37 @@ Each request must belong to an open agent turn. The service appends a paired `ap
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot.
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## Model Experience
### System prompt and policy notice
### Current approval policy context
#### What the model sees
Under `ask`, every agent request carries the ask-policy prompt section below. Under `never`, it carries the never-policy prompt section below. A policy switch injects exactly `The approval policy changed from "<old>" to "<new>" (changed by the user).` or `The approval policy changed from "<old>" to "<new>" (changed by the operator/config).` before the next step.
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
##### Ask-policy prompt section
##### Ask-policy contribution
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never-policy prompt section
##### Never-policy contribution
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token effect
Small fixed per-request cost, larger under `never`; a change notice is conditional and retained in history.
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
#### KV Cache effect
Prefix-stable while the approval policy is unchanged. An `ask`/`never` switch changes the system-prompt section and invalidates reuse from its first changed token; the accompanying notice is append-only.
Append-only after retained history. An `ask`/`never` switch preserves the stable system and conversation prefix instead of rewriting the first wire message.
### Tool outcome

View File

@@ -8,38 +8,37 @@
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求每项部署应当组合一个最终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其负责的会话提供一次性机器决定。
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照
工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验
### 系统提示词与策略通知
### 当前审批策略上下文
#### 模型看到的内容
`ask` 下,每个 agent 请求都会携带下方的 ask 策略提示词段。在 `never` 下,请求会携带下方的 never 策略提示词段。策略切换会在下一步骤前精确注入 `The approval policy changed from "<old>" to "<new>" (changed by the user).``The approval policy changed from "<old>" to "<new>" (changed by the operator/config).`
首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,批准贡献会说明可咨询已配置的应答者,缺少应答者时以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息
##### Ask 策略提示词段
##### Ask 策略贡献
```markdown
<!-- dsh-user-approval-policy:ask -->
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
```
##### Never 策略提示词段
##### Never 策略贡献
```markdown
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
<!-- dsh-user-approval-policy:never -->
```
#### Token 影响
每个请求有少量固定成本,`never` 下的成本更高;变更通知按条件出现,并保留在历史中
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token
#### KV Cache 影响
审批策略不变时,前缀保持稳定`ask``never` 切换会改变系统提示词段,并从首个变化的 token 开始使复用失效;随附通知只会追加
在保留的历史之后仅追加`ask``never` 切换会保留稳定的系统与对话前缀,而不会改写第一条 wire 消息
### 工具结果

View File

@@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -59,7 +59,7 @@ declare module '@deepseek-ai/dsh-session' {
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the prompt section and the narrator's notices). The LAST such
* from the cache-safe runtime-context snapshot). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy}).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
@@ -90,41 +90,17 @@ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cance
* (exactly today's behavior).
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
* deterministically. The strict headless stance (CI, unattended runs) and
* the only policy value stated in the system prompt — unlike `'ask'`, its
* outcome is knowable without asking, so stating it cannot overclaim.
* the policy whose outcome is knowable without asking.
*/
export type ApprovalPolicy = 'ask' | 'never'
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
/**
* The prompt sentence stating a `'never'` policy — visibility for the one
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
* does NOT parse this prose: deployments can quote it in a persona or another
* section, so the section also emits a source-owned marker.
*/
/** Model-facing statement for the deterministic `'never'` policy. */
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
const POLICY_MARKERS = {
ask: '<!-- dsh-user-approval-policy:ask -->',
never: '<!-- dsh-user-approval-policy:never -->',
} as const satisfies Record<ApprovalPolicy, string>
/**
* Read the policy fact emitted by this service from a logged system prompt.
* The section is ordered after deployment persona text, and the last marker
* wins so a persona quoting an earlier marker cannot shadow the service's own
* contribution. Ordinary policy prose is deliberately ignored.
*/
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
if (system === undefined) return undefined
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
const never = system.lastIndexOf(POLICY_MARKERS.never)
if (ask < 0 && never < 0) return undefined
return never > ask ? 'never' : 'ask'
}
/** Model-facing statement for an interactive policy that may still fail closed. */
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/**
* The session's approval-policy override: the last `approval/policy` event in
@@ -212,7 +188,7 @@ export interface Config {
/**
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
* changes to the model through prompt and pre-step notices.
* changes to the model through the cache-safe runtime-context snapshot.
*/
export class ApprovalService extends Service {
static Config: z<Config> = z.object({
@@ -224,9 +200,10 @@ export class ApprovalService extends Service {
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// State only deterministic policy; a marker records the otherwise silent state.
// The complete current value travels after retained history, so switching
// policy does not rewrite the stable system-prompt cache prefix.
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.section({
scope.systemPrompt.context({
name: 'approval:policy',
order: 115,
text: (context) => {
@@ -234,54 +211,10 @@ export class ApprovalService extends Service {
// A bare assemble() (tests, diagnostics) has no session to state.
if (agent === undefined) return ''
const policy = effective(agent)
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
},
})
})
// Visibility layer 2: the boundary narrator. agent/step runs before the
// request history is derived, so the notice is
// seen by THIS step's request: idle-time flip-flops coalesce at the
// turn's first step (net-zero → nothing), and a mid-turn switch is
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
ctx.on('agent/step', (agent) => {
const session = agent.session
const events = session.events
let overrideIndex = -1
let overrideSource: 'delegation' | undefined
let headerIndex = -1
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
overrideSource = event.data.source
} else if (headerIndex < 0 && event.type === 'request/header') {
headerIndex = index
}
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
// Cold start (nothing ever told) narrates nothing — the section about
// to go out states the truth, and there is no delta to explain.
if (told === undefined || told === current) return
const cause = overrideSource === 'delegation'
? 'inherited from the delegating session'
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
agent.inject(createUserMessage({
content: [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
source: { kind: 'plugin', plugin: 'user-approval' },
}))
})
}
/**

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
@@ -351,33 +351,17 @@ describe('ApprovalService.request', () => {
describe('approval policy (the approval/policy fold)', () => {
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
/**
* An agent stand-in over a REAL Session — gate, section, and narrator fold
* real events; the opened turn satisfies request()'s enclosure precondition.
*/
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
/** Agent stand-in over a real Session; the opened turn satisfies request()'s enclosure precondition. */
function sessionAgent(id: string): { agent: Agent; session: Session } {
const session = new Session(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
session,
inject: (input: { content: Array<{ type: string; text: string }> }) => {
injected.push(input.content[0]?.text ?? '')
},
} as unknown as Agent
return { agent, session, injected }
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
/** Append a `request/header` snapshot whose system text is exactly `system`. */
function appendHeader(session: Session, system: string): void {
session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system }, reason: 'initial' })
return { agent, session }
}
it('folds to the last event, or undefined without one', () => {
@@ -464,131 +448,46 @@ describe('approval policy (the approval/policy fold)', () => {
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
})
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
it('contributes the complete current ask or never policy as cache-safe context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const askAgent = sessionAgent('sess-sect-ask').agent
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
setApprovalPolicy(session, 'never')
const sectionFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
const contextFor = async (context: object) =>
(await ctx.systemPrompt.assemble(context)).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor({ agent: askAgent })).toBe(ASK_SENTENCE)
expect(await contextFor({ agent: neverAgent })).toBe(NEVER_SENTENCE)
// A bare assemble (no agent) has no session to state.
expect(await sectionFor({})).toBe('')
expect(await contextFor({})).toBe('')
})
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
it('reflects the latest durable switch and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-1')
await preStep(ctx, agent)
expect(injected).toEqual([])
const { agent, session } = sessionAgent('sess-context-switch')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent })).contexts.find(entry => entry.name === 'approval:policy')?.text
expect(await contextFor()).toBe(ASK_SENTENCE)
expect(await contextFor()).toBe(ASK_SENTENCE)
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
setApprovalPolicy(session, 'ask')
setApprovalPolicy(session, 'never')
await preStep(ctx, agent)
expect(injected).toHaveLength(1)
expect(await contextFor()).toBe(NEVER_SENTENCE)
expect(await contextFor()).toBe(NEVER_SENTENCE)
})
it('reads what the model was told back from the folded header text after a restart', async () => {
// A session whose last request carried the never sentence resumes under
// an ask default: the narrator attributes the change to the operator.
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-2')
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
})
it('attributes a constructor-seeded policy event to delegation', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-inherited')
appendHeader(session, ASK_MARKER)
session.append('approval/policy', { policy: 'never', source: 'delegation' })
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (inherited from the delegating session).'])
})
it('narrates a config default drift from the logged ask marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-3')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
})
it('a pinned override survives a default change silently', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-4')
appendHeader(session, `persona only\n${ASK_MARKER}`)
setApprovalPolicy(session, 'ask')
appendHeader(session, `persona only\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('does not infer never from deployment prose that quotes the never sentence', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-prose')
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('treats a legacy header with no source-owned marker as untold', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent, session, injected } = sessionAgent('sess-narr-unmarked-header')
appendHeader(session, 'legacy persona-only header')
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('uses the service marker after an earlier persona marker', async () => {
const ctx = new Context()
await ctx.plugin(ApprovalService)
const { agent, session, injected } = sessionAgent('sess-narr-spoof-marker')
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
await preStep(ctx, agent)
expect(injected).toEqual([])
})
it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => {
it('disposes the service context contribution with its fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
const fiber = await ctx.plugin(ApprovalService)
const live = sessionAgent('sess-hmr-service-live')
const afterDispose = sessionAgent('sess-hmr-service-disposed')
const sectionFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
expect(await sectionFor()).toBeDefined()
appendHeader(live.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(live.session, 'never')
await preStep(ctx, live.agent)
expect(live.injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
setApprovalPolicy(afterDispose.session, 'never')
const contextFor = async () =>
(await ctx.systemPrompt.assemble({ agent: live.agent })).contexts.find(context => context.name === 'approval:policy')
expect(await contextFor()).toBeDefined()
await fiber.dispose()
expect(await sectionFor()).toBeUndefined()
await preStep(ctx, afterDispose.agent)
expect(afterDispose.injected).toEqual([])
expect(await contextFor()).toBeUndefined()
})
})

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/web/tool-web/README.md
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07
README.md: 12f5c806db66b2109888c1ec642d117f3432d0df
README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs.
## Tools
@@ -47,14 +47,20 @@ The tool never calls a provider's `available()` and never enumerates providers
#### What the model sees
Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections.
Search and fetch contribute the web-search and web-fetch guidance below. Search chooses its fetch-enabled or search-only text from config at registration time. A scoped tool restriction does not remove these independently registered sections.
##### Web search guidance
##### Web search guidance with fetch enabled
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
```
##### Web search-only guidance
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
```
##### Web fetch guidance
```markdown
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
#### Token effect
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema.
Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section.
#### KV Cache effect
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it.
### Tool schemas

View File

@@ -4,7 +4,7 @@
面向模型的 web 工具套件 `web_search``web_fetch`,构建于 [web 能力 seam](../web/README.md)`ctx.web`之上。它只负责面向模型的事项工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall``presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`该包package绝不导入具体提供方。两个工具都不公开面向模型的超时每个工具的协作式工具调用超时预算通过配置在此声明`fetchTimeoutMs``searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }``{ fetch: false }`)。
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }``{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。
## 工具
@@ -47,14 +47,20 @@
#### 模型看到的内容
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。
搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。搜索会在注册时根据配置选用启用抓取或仅搜索的文本。scope 工具限制不会移除这些独立注册的区段。
##### Web 搜索指引
##### 启用抓取时的 Web 搜索指引
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.
```
##### 仅搜索时的 Web 搜索指引
```markdown
Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
```
##### Web 抓取指引
```markdown
@@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex
#### Token 影响
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。
每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。
#### KV Cache 影响
只要启用工具、scope 与指引文本不变前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效scope schema 限制不会移除该区段。
只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效scope schema 限制不会移除该区段。
### 工具 schema

View File

@@ -84,6 +84,8 @@ export function apply(ctx: Context, config: Config): void {
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
if (resolved.search) {
applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch)
}
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars)
}

View File

@@ -204,12 +204,21 @@ export function presentSearchResult(args: { query: string }, result: ToolResult)
* request's `maxResults`.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
* @param fetchEnabled - whether the same composition exposes `web_fetch`, which
* controls whether search guidance may recommend that follow-up tool.
*/
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
export function applyWebSearchTool(
ctx: Context,
maxResults: number,
timeoutMs: number,
fetchEnabled: boolean,
): void {
ctx.systemPrompt.section({
name: 'tool:web_search',
order: 110,
text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.',
text: fetchEnabled
? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.'
: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.',
})
ctx.tools.register(defineTool({

View File

@@ -483,8 +483,17 @@ describe('tool-web registration', () => {
const { fiber, ctx } = await mountTools()
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain('web_search')
expect(text).toContain('web_fetch')
expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.')
expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
await fiber.dispose()
})
it('does not advertise web_fetch in search-only prompt guidance', async () => {
const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
const prompt = await ctx.systemPrompt.assemble()
const text = prompt.sections.map(s => s.text).join('\n')
expect(text).toContain('Use the returned source snippets when available')
expect(text).not.toContain('web_fetch')
await fiber.dispose()
})
})

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/web/web-search-deepseek/README.md
README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d
README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682
README.md: 9046934de209ed0787efa50332e5be16bfdf55c6
README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`.
This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, records the auxiliary request in the initiating Agent session when one exists, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`.
## How it differs from a dedicated search endpoint
@@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead
**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable.
It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses.
It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart.
## Config
| Key | Default | Meaning |
|---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
| `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
@@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
```
@@ -41,6 +42,10 @@ Results are deduplicated by URL because one request may surface the same page ac
Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
## Request logging
Immediately before dispatch, a search running under an initiating Agent appends the log-only `web/deepseek-search-llm-request` session event. It contains the resolved endpoint, API version, and exact secret-free JSON body sent to DeepSeek; headers and credentials are excluded. Credential failures and cancellations before dispatch create no event, while later HTTP or response failures leave the attempted request durable. Direct programmatic provider calls outside an Agent have no initiating session to log.
## Model Experience
### Auxiliary DeepSeek search request
@@ -61,7 +66,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat
#### What the model sees
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: <error>`, `DeepSeek search aborted`, `DeepSeek search request failed: <error>`, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: <error>`; HTTP failures preserve the provider message. The consumer owns the error wrapper.
#### Token effect
@@ -74,6 +79,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint.
- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. Caller cancellation races this preflight locally, but cannot force an arbitrary credential backend itself to stop work.
- **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation.
- **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL.
- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`.

View File

@@ -4,7 +4,7 @@
由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`
这是一个**实现**包package它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`。Anthropic 协议格式wire format是提供方私有细节并**不**使该提供方依赖 `ctx.llm`
这是一个**实现**包package它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent智能体会话还会在其中记录该辅助请求不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`。Anthropic 协议格式wire format是提供方私有细节并**不**使该提供方依赖 `ctx.llm`
## 与专用搜索端点的区别
@@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点DeepSeek 则没有。该提供方
**严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。
它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`不同于大语言模型LLM适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。
它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`不同于大语言模型LLM适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key``Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 |
| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 |
| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 |
| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 |
| `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 |
| `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 |
@@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点DeepSeek 则没有。该提供方
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
```
@@ -41,6 +42,10 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。
## 请求日志
由 agent 发起的搜索会在发出请求前一刻,向相应会话追加仅用于日志的 `web/deepseek-search-llm-request` 会话事件。其中包含已解析端点、API 版本,以及发送给 DeepSeek 且不含密钥的精确 JSON 请求体;不包含标头和凭据。发出请求前发生凭据处理失败或取消时不会创建事件;发出请求后才发生 HTTP 或响应失败时,本次请求尝试仍保留持久记录。在 agent 之外通过程序直接调用提供方时,没有发起会话可供记录。
## 模型体验
### 辅助 DeepSeek 搜索请求
@@ -61,7 +66,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
#### 模型看到的内容
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet提供方文本不会作为答案受到信任。该提供方的具体错误消息`DeepSeek search aborted``DeepSeek search request failed: <error>``DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search``DeepSeek returned an unprocessable response body: <error>`HTTP 失败保留提供方消息。错误包装属于消费方。
通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: <error>``DeepSeek search aborted``DeepSeek search request failed: <error>``DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search``DeepSeek returned an unprocessable response body: <error>`HTTP 失败保留提供方消息。错误包装属于消费方。
#### Token 影响
@@ -74,6 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案
## 已知限制与暂缓事项
- **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token并且最多执行 `maxUses` 次服务器侧搜索DeepSeek 不公开专用检索端点。
- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。调用方取消在本地与该预检存在竞态,但无法强制任意凭据后端自行停止工作。
- **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。
- **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。
- **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout``TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`

View File

@@ -27,7 +27,10 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-web": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -35,7 +38,11 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -7,6 +7,9 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -26,7 +29,7 @@ export {
DEEPSEEK_DEFAULT_MODEL,
DEEPSEEK_PROVIDER_ID,
} from './provider.ts'
export type { DeepSeekSearchProviderOptions } from './provider.ts'
export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'web-search-deepseek'
@@ -34,10 +37,14 @@ export const name = 'web-search-deepseek'
/** The web seam this provider registers into. */
export const inject = ['web']
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
export interface Config {
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
/** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
apiKey?: string
/** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Anthropic-compatible endpoint base; `/messages` is appended. */
baseURL?: string
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
@@ -51,7 +58,8 @@ export interface Config {
}
export const Config: z<Config> = z.object({
apiKey: z.string(),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
model: z.string(),
apiVersion: z.string(),
@@ -63,12 +71,29 @@ export const Config: z<Config> = z.object({
export function apply(ctx: Context, config: Config): void {
const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS
const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES
const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV)
const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0
? config.apiKey
: undefined
ctx.web.registerSearchProvider(new DeepSeekSearchProvider({
apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '',
...literalApiKey === undefined ? {} : { apiKey: literalApiKey },
resolveApiKey: async () => {
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
const ambient = process.env[apiKeyEnv]
return ambient !== undefined && ambient.length > 0 ? ambient : undefined
},
apiKeyEnv,
baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
model: config.model ?? DEEPSEEK_DEFAULT_MODEL,
apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION,
maxTokens,
maxUses,
recordRequest: (request) => {
ctx.get('agents')?.currentInitiator()?.session.append(
'web/deepseek-search-llm-request',
request,
)
},
}))
}

View File

@@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
* No runtime invariant: the package emits a pre-dispatch log event but owns no
* later authoritative dispatch event to relate it to. Exact envelope equality
* is pinned at the provider boundary instead.
*/
const install: InvariantInstaller = () => {}

View File

@@ -13,6 +13,8 @@ import type {
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-session'
import type {
AnthropicError,
AnthropicResponse,
@@ -47,10 +49,49 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
/**
* Exact secret-free DeepSeek Messages request recorded immediately before one
* auxiliary search dispatch.
*/
export interface DeepSeekSearchLlmRequest {
/** Fully resolved Messages endpoint. */
readonly endpoint: string
/** `anthropic-version` header value. */
readonly apiVersion: string
/** Exact JSON body sent to the provider. */
readonly body: {
readonly model: string
readonly max_tokens: number
readonly messages: readonly [{
readonly role: 'user'
readonly content: readonly [{
readonly type: 'text'
readonly text: string
}]
}]
readonly tools: readonly [{
readonly type: 'web_search_20250305'
readonly name: 'web_search'
readonly max_uses: number
}]
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
}
}
/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */
export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent makes the provider unavailable. */
apiKey: string
/** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */
apiKey?: string
/** Resolve the current DeepSeek API key for one search operation. */
resolveApiKey?: () => Promise<string | undefined>
/** Credential reference named by missing-credential diagnostics. */
apiKeyEnv?: CredentialRef
/** Endpoint base; `/messages` is appended. */
baseURL: string
/** Anthropic-format model name. */
@@ -61,6 +102,11 @@ export interface DeepSeekSearchProviderOptions {
maxTokens: number
/** Maximum `web_search` server-tool uses per request. */
maxUses: number
/**
* Record the exact secret-free request immediately before dispatch. A throw
* prevents dispatch so model-visible auxiliary input cannot escape logging.
*/
recordRequest?: (request: DeepSeekSearchLlmRequest) => void
}
/**
@@ -134,41 +180,51 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
constructor(private readonly options: DeepSeekSearchProviderOptions) {}
available(): boolean {
return this.options.apiKey.length > 0
return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined)
&& URL.canParse(this.options.baseURL)
&& isPositiveInteger(this.options.maxTokens)
&& isPositiveInteger(this.options.maxUses)
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const apiKey = await this.apiKey(signal)
throwIfSearchAborted(signal)
const endpoint = `${this.options.baseURL}/messages`
const body: DeepSeekSearchLlmRequest['body'] = {
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}
this.options.recordRequest?.({
endpoint,
apiVersion: this.options.apiVersion,
body,
})
throwIfSearchAborted(signal)
let response: Response
try {
response = await fetch(`${this.options.baseURL}/messages`, {
response = await fetch(endpoint, {
method: 'POST',
redirect: 'error',
headers: {
// Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
// may expect `Authorization: Bearer` — send both so either resolves.
'x-api-key': this.options.apiKey,
'authorization': `Bearer ${this.options.apiKey}`,
'x-api-key': apiKey,
'authorization': `Bearer ${apiKey}`,
'anthropic-version': this.options.apiVersion,
'content-type': 'application/json',
'accept': 'application/json',
'user-agent': USER_AGENT,
},
body: JSON.stringify({
model: this.options.model,
max_tokens: this.options.maxTokens,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }],
}],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
}),
body: JSON.stringify(body),
...signal !== undefined ? { signal } : {},
})
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
@@ -183,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
// An abort fired mid-body must surface as WEB_ABORTED, not be swallowed
// into a generic HTTP-error message — cancellation is not a provider
// error (the seam's cancellation contract).
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
// Otherwise: the HTTP status is already captured in `message` above; a
// malformed/non-JSON error body (normal for gateway 5xx/429s) can only
// cost a richer provider message, never the real error.
@@ -195,11 +251,72 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
const payload = await response.json() as AnthropicResponse
return mapAnthropicResponse(payload)
} catch (error: unknown) {
if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
if (error instanceof WebError) throw error
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
}
}
/** Resolve one operation's credential without retaining it on the provider. */
private async apiKey(signal?: AbortSignal): Promise<string> {
throwIfSearchAborted(signal)
if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey
let resolved: string | undefined
try {
resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal)
} catch (error: unknown) {
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error)
throw new WebError(
`DeepSeek search credential resolution failed: ${String(error)}`,
'WEB_PROVIDER_ERROR',
{ cause: error },
)
}
if (resolved !== undefined && resolved.length > 0) return resolved
const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'
throw new WebError(
`DeepSeek search has no API key for "${ref}"; store it through the credentials service`
+ ' (the web Models page writes it), export it in the launching environment, or set a literal'
+ ' "apiKey" in the web-search-deepseek config',
'WEB_PROVIDER_CREDENTIAL_MISSING',
)
}
}
/**
* Race a same-process asynchronous preflight against caller cancellation. The
* attached settlement handlers keep observing an uncooperative operation after
* abort so a later rejection cannot become unhandled.
*/
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return operation
if (signal.aborted) return Promise.reject(searchAborted(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(searchAborted(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error }))
},
)
})
}
/** Throw the provider's stable cancellation error when the caller already aborted. */
function throwIfSearchAborted(signal?: AbortSignal): void {
if (signal?.aborted === true) throw searchAborted(signal)
}
/** Build the provider's stable cancellation error while retaining the caller's reason. */
function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError {
return new WebError('DeepSeek search aborted', 'WEB_ABORTED', {
cause: signal?.aborted === true ? signal.reason : fallback,
})
}
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */

View File

@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import WebService from '@deepseek-ai/dsh-web'
import {
DeepSeekSearchProvider,
@@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => {
})
describe('DeepSeekSearchProvider request mapping', () => {
it('posts an Anthropic Messages request enabling the web_search server tool', async () => {
it('records and posts the same Anthropic Messages request with the web_search server tool', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
const recordRequest = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await new DeepSeekSearchProvider(options).search({ query: 'hello' })
await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
@@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => {
expect(headers['x-api-key']).toBe('ds-key')
expect(headers['authorization']).toBe('Bearer ds-key')
expect(headers['anthropic-version']).toBe('2023-06-01')
expect(JSON.parse(init.body as string)).toEqual({
const body = {
model: 'deepseek-chat',
max_tokens: 4096,
messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }],
tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }],
}
expect(JSON.parse(init.body as string)).toEqual(body)
expect(recordRequest).toHaveBeenCalledOnce()
expect(recordRequest).toHaveBeenCalledWith({
endpoint: url,
apiVersion: '2023-06-01',
body,
})
expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0)
})
it('forwards the abort signal', async () => {
@@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => {
})
describe('DeepSeekSearchProvider error handling', () => {
it('does not start credential resolution or dispatch for a pre-aborted call', async () => {
const resolveApiKey = vi.fn(async () => 'late-key')
const recordRequest = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey,
recordRequest,
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(resolveApiKey).not.toHaveBeenCalled()
expect(recordRequest).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('aborts while an uncooperative credential resolver remains pending', async () => {
const resolveApiKey = vi.fn(() => new Promise<string>(() => {}))
const recordRequest = vi.fn()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
const search = new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey,
recordRequest,
}).search({ query: 'q' }, controller.signal)
controller.abort(new Error('deadline'))
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(resolveApiKey).toHaveBeenCalledOnce()
expect(recordRequest).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalled()
})
it('resolves credentials under an active cancellation signal', async () => {
const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: async () => 'resolved-key',
}).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false })
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect((init.headers as Record<string, string>)['x-api-key']).toBe('resolved-key')
})
it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => {
const controller = new AbortController()
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: () => Promise.reject(new Error('credential backend failed')),
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({
code: 'WEB_PROVIDER_ERROR',
message: 'DeepSeek search credential resolution failed: Error: credential backend failed',
}))
})
it('uses the default credential reference when no resolver is configured', async () => {
await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' }))
.rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"')
})
it('observes cancellation triggered synchronously by credential resolution', async () => {
const controller = new AbortController()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await expect(new DeepSeekSearchProvider({
...options,
apiKey: '',
resolveApiKey: () => {
controller.abort(new Error('resolver cancelled caller'))
return Promise.resolve('unused-key')
},
}).search({ query: 'q' }, controller.signal))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
expect(fetchMock).not.toHaveBeenCalled()
})
it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
@@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => {
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps a custom abort reason to WEB_ABORTED', async () => {
const controller = new AbortController()
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true })
})))
const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
controller.abort(new Error('timeout reason'))
await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
})
it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 })))
await expect(new DeepSeekSearchProvider(options).search({ query: 'q' }))
@@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => {
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
const fiber = await ctx.plugin(deepseekPlugin, {})
deepseekPlugin.apply(ctx, {})
await ctx.web.search({ query: 'q' })
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
expect((init.headers as Record<string, string>)['x-api-key']).toBe('env-key')
expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' })
await fiber.dispose()
await ctx.fiber.dispose()
} finally {
if (prev === undefined) delete process.env.DEEPSEEK_API_KEY
else process.env.DEEPSEEK_API_KEY = prev
}
})
it('is unavailable when neither config nor env supplies a key', async () => {
it('resolves the credential for each search so a stored or rotated key needs no restart', async () => {
const previous = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-'))
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse()))
vi.stubGlobal('fetch', fetchMock)
const ctx = new Context()
try {
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' })
await expect(ctx.web.search({ query: 'missing' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }))
const ref = credentialRef('DEEPSEEK_API_KEY')
await ctx.credentials.set(ref, 'stored-key')
await ctx.web.search({ query: 'stored' })
await ctx.credentials.set(ref, 'rotated-key')
await ctx.web.search({ query: 'rotated' })
const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record<string, string>)
expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key'])
} finally {
await ctx.fiber.dispose()
await rm(dir, { recursive: true, force: true })
if (previous === undefined) delete process.env.DEEPSEEK_API_KEY
else process.env.DEEPSEEK_API_KEY = previous
}
})
it('reports an actionable credential error when neither config nor env supplies a key', async () => {
const prev = process.env.DEEPSEEK_API_KEY
delete process.env.DEEPSEEK_API_KEY
try {
const ctx = new Context()
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
await ctx.plugin(deepseekPlugin, {})
await expect(ctx.web.search({ query: 'q' }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
let caught: unknown
try {
await ctx.web.search({ query: 'q' })
} catch (error: unknown) {
caught = error
}
expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })
if (!(caught instanceof Error)) throw new Error('search did not throw an Error')
expect(caught.message).toMatch(/store it through the credentials service.*Models page/s)
} finally {
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
}

View File

@@ -20,6 +20,15 @@
{
"path": "../web"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../support/invariants"
}