mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge commit '74a6e1e5cf520cdde8bf461d9051c81142fd6afe' into codex/product-subagent-runtime-refresh-claude-code
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
|
||||
2026-06-24-web-capability-seam.md: 7e7b09f19864bd2ad8ad9d69579c1d5c79600cde
|
||||
2026-06-24-web-capability-seam.zh.md: dbb41ee42d2c7503955ead2df32abe80b3a4f641
|
||||
2026-06-24-web-capability-seam.md: 8c6c088ea5d7345f9955892b2d6054cfae518bbd
|
||||
2026-06-24-web-capability-seam.zh.md: 4921c4a4647d180dbb00e6493a19d38584f99bdc
|
||||
|
||||
@@ -199,7 +199,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis
|
||||
|
||||
## Fetch request and result schema
|
||||
|
||||
The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).)
|
||||
The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services.
|
||||
|
||||
The seam request stays smaller than OpenCode's model-facing tool:
|
||||
|
||||
@@ -235,12 +235,14 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir
|
||||
The fetch provider's resource controls:
|
||||
|
||||
- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
|
||||
- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected.
|
||||
- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation.
|
||||
- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
|
||||
- Abort signals propagate through network fetches and expensive decoding.
|
||||
- Only same-origin redirects are followed automatically; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
|
||||
- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
|
||||
- Requests carry an explicit product user agent rather than silently impersonating a browser.
|
||||
|
||||
SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets.
|
||||
The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy.
|
||||
|
||||
## Tool consumer behavior
|
||||
|
||||
@@ -252,7 +254,7 @@ Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web`
|
||||
|
||||
Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
|
||||
|
||||
The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
|
||||
The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. Every successful result labels provider-controlled text as external untrusted data. Fetch conversion removes active and hidden HTML content; unsafe conversion returns a fixed omission marker rather than raw HTML.
|
||||
|
||||
The model-facing output is text-first because tool results are `ContentBlock[]`, but the seam outcome stays structured so UI presentation and future adapters do not have to scrape rendered text.
|
||||
|
||||
@@ -308,6 +310,18 @@ Rejected for the first version. Those providers often return extracted or summar
|
||||
|
||||
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
|
||||
|
||||
### Validate DNS and then call an ordinary fetch
|
||||
|
||||
Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior.
|
||||
|
||||
### Block private-looking hostname strings without pinning resolved addresses
|
||||
|
||||
Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it.
|
||||
|
||||
### Require per-call approval before public fetches
|
||||
|
||||
Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
|
||||
@@ -318,19 +332,16 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p
|
||||
|
||||
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
|
||||
|
||||
**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can reach sensitive network targets or exfiltrate data through URLs. Only the basic transport hygiene ships (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets.
|
||||
**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
|
||||
|
||||
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
|
||||
|
||||
## Deferred work
|
||||
|
||||
- SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets.
|
||||
- A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled.
|
||||
- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently.
|
||||
- Permission policy integration: the permission system now exists ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)) but bundles only sandbox mode and approval policy; web permission policy remains unintegrated.
|
||||
- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution?
|
||||
- Where should permission policy for public web access live in the shipped permission system ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)): a dedicated web permission plugin on `tools/execute`, provider config, or both?
|
||||
|
||||
@@ -199,7 +199,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`
|
||||
|
||||
## Fetch 请求与结果 schema
|
||||
|
||||
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述基本传输卫生措施(仅 http/https、拒绝 URL 中的凭证、字节/时间上限、跨源重定向阻断),解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。(完整的 SSRF/私有网络阻断推迟——见[推迟工作](#deferred-work)。)
|
||||
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。
|
||||
|
||||
seam 请求比 OpenCode 的面向模型工具更小:
|
||||
|
||||
@@ -235,12 +235,14 @@ export type WebFetchBody =
|
||||
fetch 提供方的资源控制:
|
||||
|
||||
- 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。
|
||||
- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。
|
||||
- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。
|
||||
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
|
||||
- Abort 信号传播到网络获取和高开销解码。
|
||||
- 仅自动跟随同源重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用,从而触发新的提供方/权限决策。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
|
||||
- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
|
||||
- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。
|
||||
|
||||
SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他非公开目的地,通过先 DNS 解析再验证 IP 来防御 rebinding,并在重定向的每一跳重新验证)**推迟**——见[推迟工作](#deferred-work)。在其落地之前,`web_fetch` 是一个 SSRF 原语,不得在能触达敏感内部网络目标的部署中启用。
|
||||
只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
|
||||
|
||||
## 工具消费方行为
|
||||
|
||||
@@ -252,7 +254,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
|
||||
|
||||
提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。
|
||||
|
||||
提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。
|
||||
提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。每个成功结果都会把提供方控制的文本标记为外部不可信数据。抓取转换会移除主动内容与隐藏 HTML 内容;无法安全转换时返回固定省略标记,而非原始 HTML。
|
||||
|
||||
面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。
|
||||
|
||||
@@ -308,6 +310,18 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
|
||||
|
||||
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。
|
||||
|
||||
### 验证 DNS 后调用普通 fetch
|
||||
|
||||
否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。
|
||||
|
||||
### 只阻断看起来像私网的 hostname 字符串,不固定解析地址
|
||||
|
||||
否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。
|
||||
|
||||
### 在公开抓取前要求逐次审批
|
||||
|
||||
已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
|
||||
|
||||
## 后果
|
||||
|
||||
**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
|
||||
@@ -318,7 +332,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
|
||||
|
||||
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
|
||||
|
||||
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 能触达敏感网络目标或通过 URL 外泄数据。仅交付基本传输卫生措施(仅 http/https、拒绝凭证、字节/时间上限、跨源重定向阻断);SSRF/私有网络阻断推迟(见[推迟工作](#deferred-work)),因此在其落地之前,`web_fetch` 不得在能触达内部目标的环境中启用。
|
||||
**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
|
||||
|
||||
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
|
||||
|
||||
@@ -326,13 +340,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他
|
||||
|
||||
## 推迟工作
|
||||
|
||||
- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。
|
||||
- `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
|
||||
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。
|
||||
- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。
|
||||
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出?
|
||||
- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.zh.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.zh.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
|
||||
2026-07-29-projected-token-usage-and-request-context.md: f2179885512bcb216ecb191ce98b535db571807a
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: e4435b6245d1e20b51fc2cc1d73151ced8d94731
|
||||
2026-07-29-projected-token-usage-and-request-context.md: 063f2300f378f6f7763bce87b11add5da3093230
|
||||
2026-07-29-projected-token-usage-and-request-context.zh.md: 37b8741d09e9ec56f6b9f273e05460b2deb4f6f9
|
||||
|
||||
+4
-2
@@ -14,7 +14,9 @@ Context occupancy needs a numerator and a denominator that no existing surface c
|
||||
|
||||
Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
|
||||
|
||||
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
|
||||
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value replaces the earlier sample from the same model attempt instead of double-counting it. A matching `llm/retry-started` boundary ends that replacement scope, so a retry with the same `(turn, step)` contributes a new attempt. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
|
||||
|
||||
Token-meter also owns the shared pure attempt/Turn fold over durable events. It applies the same retry boundary while adding the stricter completeness and exact-total checks required by an exact per-Turn disclosure. A presentation consumer may select a complete Turn window and invoke that fold, but does not own or duplicate the accounting semantics.
|
||||
|
||||
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional `contextWindow` from the newest `request/context` record. Neither field is synthesized before its source exists.
|
||||
|
||||
@@ -56,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec
|
||||
|
||||
Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
|
||||
|
||||
Each session log gains one small `request/context` record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.
|
||||
Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.
|
||||
|
||||
+4
-2
@@ -14,7 +14,9 @@ Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗
|
||||
|
||||
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
|
||||
|
||||
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前样本,不会重复计数。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
|
||||
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;`assistant/message` 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 `llm/retry-started` 边界会结束该替换作用域,因此复用同一 `(turn, step)` 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
|
||||
|
||||
token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。
|
||||
|
||||
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。在各自来源出现前,两个字段都不会被合成。
|
||||
|
||||
@@ -56,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定,
|
||||
|
||||
占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
|
||||
|
||||
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 投影是持久会话投影用量语义的正典所有方;TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。
|
||||
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md
|
||||
2026-08-09-headless-direct-core-entry-point.md: 8ed979794afa008588d1b849f0074e8696e6e43f
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: 512d4b88c921431fe26afd9f62c34a1939ac5bdd
|
||||
2026-08-09-headless-direct-core-entry-point.md: 9c17b8d418924c38174b4d958fd54b057b118019
|
||||
2026-08-09-headless-direct-core-entry-point.zh.md: d95978a832d52b26b1139cabb4b23ade93ce0da3
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@ English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, empty stderr on success, and no listening port. A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree.
|
||||
The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree.
|
||||
|
||||
The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state.
|
||||
|
||||
@@ -14,17 +14,17 @@ The direct entry point still needs the same deployment model state as Web-create
|
||||
|
||||
The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation.
|
||||
|
||||
`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. A terminal `error` reason writes its durable code and message to stderr; unexpected driver failures also use stderr and exit 1.
|
||||
`headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. [Headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns the live stderr projection; a terminal `error` reason writes its durable code and message there, and unexpected driver failures also use stderr and exit 1.
|
||||
|
||||
`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections.
|
||||
|
||||
`loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched.
|
||||
|
||||
This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default.
|
||||
This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default.
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal.
|
||||
Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose both reasoning progress and a terminal model failure on stderr. Built-bin acceptance reaches a mock DeepSeek endpoint through the published entry and requires streamed reasoning on stderr, final text on stdout, and exit 0. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`.
|
||||
`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`.
|
||||
|
||||
ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free.
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,成功时 stderr 为空,并且不打开监听端口。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。
|
||||
`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。
|
||||
|
||||
直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。
|
||||
|
||||
@@ -14,17 +14,17 @@ Status: implemented
|
||||
|
||||
随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。
|
||||
|
||||
`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。结束原因为 `error` 时,其持久化错误码与消息写入 stderr;驱动器的意外失败也写入 stderr 并以 1 退出。
|
||||
`headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。[Headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责实时 stderr 投影;结束原因为 `error` 时,其持久化错误码与消息写入 stderr,驱动器的意外失败也写入 stderr 并以 1 退出。
|
||||
|
||||
`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。
|
||||
|
||||
`loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。
|
||||
|
||||
本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.zh.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。
|
||||
本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.zh.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。
|
||||
|
||||
## 验证
|
||||
|
||||
包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。
|
||||
包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露推理进度与终止态模型失败。构建后二进制验收通过已发布入口访问 mock DeepSeek 端点,并要求推理流出现在 stderr、最终文本出现在 stdout 且退出状态为 0。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -39,6 +39,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。
|
||||
`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。
|
||||
|
||||
ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md
|
||||
2026-07-23-web-permission-and-approval.md: 9fba57e37e0d26a6cb40a81e9330ecfaa9e881b9
|
||||
2026-07-23-web-permission-and-approval.zh.md: 0a030d60dcf94e83adc41a21aee850d839d1af01
|
||||
2026-07-23-web-permission-and-approval.md: f533adc43e7aafe24d09f7998ba8f7336dea3c79
|
||||
2026-07-23-web-permission-and-approval.zh.md: 85e70bac153b4687cb46c1953784f184b8311637
|
||||
|
||||
@@ -30,4 +30,4 @@ Client-side, `Session` gained `permissions` and `setPermission`, and approval an
|
||||
|
||||
## Consequences
|
||||
|
||||
Web sessions start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-user-questions over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, the keyless web smoke for fixture-mode approval and preset switching, and real-composition plan-review and question snapshots that pin the pending sidebar status through resolution.
|
||||
Web sessions start confined (`workspace-write` + `ask` by default), and a sandbox-denial escalation reaches the browser through the approval channel. The deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering uses the same registry pattern (ui-user-questions over the question pending table), and Session navigation identifies approval, plan-review, and ordinary question waits before the user opens them. The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage includes proxy registry and permission RPC suites, session-object and fixture suites, and the keyless Web smoke for approval answering and preset switching.
|
||||
|
||||
@@ -30,4 +30,4 @@ Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-l
|
||||
|
||||
## 后果
|
||||
|
||||
Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。
|
||||
Web 会话从受限状态启动(默认 `workspace-write` + `ask`),sandbox 拒绝升级会通过审批通道抵达浏览器。部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-user-questions 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖包括 proxy 注册表与权限 RPC 单元测试套件、会话对象与 fixture 单元测试套件,以及针对审批应答与 preset 切换的无密钥 Web 冒烟测试。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
|
||||
2026-07-29-persistent-bash-str-replace-editor.md: e8e37b7e534773429a9c6fe0f63bb8d5460de364
|
||||
2026-07-29-persistent-bash-str-replace-editor.zh.md: 71034ba615e09e09ec03212b6d5535959df73f4a
|
||||
2026-07-29-persistent-bash-str-replace-editor.md: 1cab6e1b37a642dcbb07c4006a8c7851a8cf592c
|
||||
2026-07-29-persistent-bash-str-replace-editor.zh.md: 0edaa0c4d594a94d2860c552504d12f3cc7fdc63
|
||||
|
||||
@@ -12,7 +12,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m
|
||||
|
||||
`@deepseek-ai/dsh-tool-bash-persistent` consumes `ctx.terminals` and registers one `bash(command)` tool. It lazily creates one interactive shell per exact Agent and serializes that owner's calls. Cwd, exported variables, activated environments, functions, and background jobs persist. Random private markers delimit command output. Retained scrollback is paged backward to recover the command's original prefix; a dropped prefix is reported explicitly. A nonzero wrapped command appends `[exit code: N]`; a shell that dies before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither. `maxOutputChars` bounds retained command output, while fixed diagnostics can extend the returned string. Timeout or cancellation closes the shell before another call can reuse uncertain state, and model-visible timeout/exit results disclose that reset. Cancellation always resets and discards the result, even when a complete status marker is already observable, so state changes the model never saw cannot survive. The configurable description defaults to persistence facts only, so network and package-mirror claims remain deployment-owned.
|
||||
|
||||
`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell.
|
||||
`@deepseek-ai/dsh-tool-str-replace-editor` independently consumes `ctx.fs` and registers `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`. It provides numbered text views, filtered two-level directory listings, unique literal replacement, canonical insertion boundaries, and bounded output. Paths are absolute; file views preserve content tabs so copied text remains valid literal replacement input; mutations preserve tabs outside the requested edit; and the public schema and failures use only `old_str`. Command-specific fields accept `null` placeholders: execution treats them as omitted when the selected command does not use them, preserves required-field checks, treats `view_range: null` as a full view, and rejects `str_replace.new_str: null` so only omission requests deletion. The plugin can compose with persistent Bash, one-shot Bash, sandboxed Bash, or no shell.
|
||||
|
||||
`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged.
|
||||
|
||||
@@ -30,6 +30,8 @@ The shipped [`minimal` agent preset](../../../../packages/preset/agent-presets/p
|
||||
|
||||
**Modify native read/write/edit.** Rejected because it would distort their general-purpose contracts instead of adding an independently composable editor.
|
||||
|
||||
**Reject every present `null` command field.** Rejected because model-generated calls may serialize placeholders for optional fields that the selected command does not use. The selected command still rejects `null` for required fields and for the deletion-sensitive `str_replace.new_str` field.
|
||||
|
||||
## Consequences
|
||||
|
||||
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
|
||||
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Nullable branches increase the command-specific fields' schema cost so unused placeholders do not force retries; execution keeps the selected command's required and deletion semantics explicit. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
|
||||
|
||||
+4
-2
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
`@deepseek-ai/dsh-tool-bash-persistent` 消费 `ctx.terminals` 并注册一个 `bash(command)` 工具。它为每个精确 Agent 惰性创建一个交互式 shell,并串行化该所有者的调用。Cwd、导出的变量、已激活环境、函数和后台任务会保留。随机私有标记划分命令输出;保留的 scrollback 会向前分页,以恢复命令真正的输出前缀,若前缀已被丢弃则明确告知。经封装的命令以非零状态结束时,会追加 `[exit code: N]`;若 shell 在报告该状态前终止,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`。`maxOutputChars` 限制保留的命令输出,而固定诊断可能使返回字符串更长。超时或取消会先关闭 shell,避免下一次调用复用状态不确定的会话,模型可见的超时/退出结果也会说明该重置。取消始终会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此,从而不会让模型未曾看到的状态变更得以保留。可配置描述默认只声明持久性事实,因此网络和软件包镜像等声明仍归部署所有。
|
||||
|
||||
`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。
|
||||
`@deepseek-ai/dsh-tool-str-replace-editor` 独立消费 `ctx.fs`,注册包含 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor`。它提供带行号文本查看、过滤后的两层目录列表、唯一字面量替换、规范插入边界和有界输出。路径必须为绝对路径;文件查看会保留内容中的制表符,因此复制的文本仍可作为有效的字面量替换输入;变更会保留请求编辑范围之外的制表符;公开 schema 与错误则只使用 `old_str`。命令专属字段接受 `null` 占位参数:当前命令不使用该字段时,执行会将其视为未提供;必填检查保持不变;`view_range: null` 表示查看完整文件;`str_replace.new_str: null` 会被拒绝,只有省略该字段才表示删除。它可以与持久 Bash、一次性 Bash、沙箱 Bash 或无 shell 组合。
|
||||
|
||||
`dsh-system-prompt` 接受 `includeHarnessIdentity: false`;`dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona,并替换 spine 的原生 Bash,而不会重复注册提示词或工具。既有默认值不变。
|
||||
|
||||
@@ -30,6 +30,8 @@ Status: implemented
|
||||
|
||||
**修改原生 read/write/edit。** 被拒绝,因为这会扭曲其通用约定,而不是增加一个可独立组合的编辑器。
|
||||
|
||||
**拒绝每个已提供的 `null` 命令字段。** 被拒绝,因为模型生成的调用可能为当前命令不使用的可选字段序列化占位参数。当前命令仍会拒绝必填字段以及对删除操作有影响的 `str_replace.new_str` 字段为 `null`。
|
||||
|
||||
## 后果
|
||||
|
||||
Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。
|
||||
Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。可为 `null` 的分支增加了命令专属字段的 schema 成本,使未使用的占位参数不会迫使模型重试;执行仍明确保留当前命令的必填与删除语义。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
|
||||
2026-07-31-even-out-shipped-tool-rosters.md: 97a9fdaedb97de77c195c319f14f972aac850726
|
||||
2026-07-31-even-out-shipped-tool-rosters.zh.md: 130573f0ddf0b94b4dcb017f58f1e0935e53e844
|
||||
2026-07-31-even-out-shipped-tool-rosters.md: 8d1af039c99fe6616745340fd1ef78b62e15b0ca
|
||||
2026-07-31-even-out-shipped-tool-rosters.zh.md: b79486516dd3f7b54e27a3e7870ce42cd84a2ebd
|
||||
|
||||
@@ -20,14 +20,10 @@ Two rows stay surface-specific. `tmux-context` is TUI-only because a browser sur
|
||||
|
||||
### What stays unmounted, and why
|
||||
|
||||
Three capabilities stay out on the evidence their own packages record, and are listed here so "we forgot" and "we decided against" stay distinguishable.
|
||||
Two capabilities stay out on the evidence their own packages record, and are listed here so "we forgot" and "we decided against" stay distinguishable.
|
||||
|
||||
**`dsh-tool-cordis`** lets the model write JavaScript and mount it as a temporary plugin. Its README states the limit: "The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node" ([Known limitations](../../../../packages/extensions/tool-cordis/README.md)). The `node:vm` realm lives inside the harness process while `dsh-sandbox-local` confines only the argv it spawns, so on the Web surface both the sandbox and the approval seam are bypassed rather than enforced.
|
||||
|
||||
**`dsh-web-fetch-http`** stays unmounted and `dsh-tool-web` keeps `fetch: false`. SSRF protection is deferred in the implementation ([`policy.ts`](../../../../packages/web/web-fetch-http/src/policy.ts) validates protocol, credentials, and length only) and the package says so: "this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets" ([README](../../../../packages/web/web-fetch-http/README.md)). The model chooses the target, which includes the harness's own gateway on loopback, private ranges, and cloud metadata endpoints.
|
||||
|
||||
Withholding it narrows the surface without removing the reach: `bash` is mounted, so `curl` gets the same page, as a live run confirmed. What the absence buys is the removal of an argument-shaped request primitive that needs no shell — and with it the accidental path where a summarization request quietly reaches loopback. A deployment that must contain outbound traffic needs a network-level control.
|
||||
|
||||
**The LSP trio** stays out for an operational reason rather than a security one: `command` resolves from `PATH` at plugin load, so a missing language server fails the whole boot rather than one tool. It becomes mountable once absence degrades to a skipped registration.
|
||||
|
||||
### MCP is a dependency, not a row
|
||||
|
||||
@@ -20,14 +20,10 @@ Status: implemented
|
||||
|
||||
### 什么保持不挂,以及为什么
|
||||
|
||||
有三项能力基于其自身包所记录的证据保持在外,列在这里是为了让「我们忘了」和「我们决定不要」保持可区分。
|
||||
有两项能力基于其自身包所记录的证据保持在外,列在这里是为了让「我们忘了」和「我们决定不要」保持可区分。
|
||||
|
||||
**`dsh-tool-cordis`** 让模型写一段 JavaScript 并挂成临时插件。它的 README 写明了这个界限:「The sandbox is containment for honest code, not a security boundary — host-realm helpers on the sandbox global are reachable, so mount code can reach Node」([Known limitations](../../../../packages/extensions/tool-cordis/README.zh.md))。`node:vm` 的 realm 就在 harness 进程内,而 `dsh-sandbox-local` 只约束它 spawn 出去的 argv,因此在 Web surface 上,沙箱与批准接缝是被绕过而非被执行。
|
||||
|
||||
**`dsh-web-fetch-http`** 保持不挂,`dsh-tool-web` 保持 `fetch: false`。SSRF 防护在实现中是 deferred 状态([`policy.ts`](../../../../packages/web/web-fetch-http/src/policy.ts) 只校验协议、凭据与长度),包里也直说了:「this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets」([README](../../../../packages/web/web-fetch-http/README.zh.md))。目标由模型选择,其中包括 harness 自己跑在环回地址上的网关、内网段和云元数据端点。
|
||||
|
||||
不挂载它收窄的是接触面而非可达性:`bash` 是挂着的,`curl` 照样能拿到同一个页面——一次真实运行确认了这点。这个缺席买到的是去掉一个无需 shell、以参数成形的请求原语,以及随之而来的那条意外路径:一次「帮我总结这个页面」悄悄打到环回地址。真要收住出站流量的部署需要的是网络层管控。
|
||||
|
||||
**LSP 三件套**留在外面是运维原因而非安全原因:`command` 在插件加载时从 `PATH` 解析,因此缺少语言服务器会让整次启动失败,而不只是失去一个工具。等到「缺失」退化为「跳过注册」之后,它就可以挂了。
|
||||
|
||||
### MCP 是依赖,不是配置行
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md
|
||||
2026-07-31-web-default-search.md: 1efa6fc939a883221e3158705236bc001313303d
|
||||
2026-07-31-web-default-search.zh.md: a782c4b58b6504c87932b379d294448b141f1305
|
||||
2026-07-31-web-default-search.md: efec6e1e94089d3bbd79296ff0eb2cb55ce005b3
|
||||
2026-07-31-web-default-search.zh.md: 825f0ee3f899c108039f6a0db59f0dfe2822cb72
|
||||
|
||||
@@ -10,13 +10,13 @@ The harness had a complete Web capability family—provider registry, DeepSeek/E
|
||||
|
||||
## Decision
|
||||
|
||||
`apps/cli/config/base.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false` and `searchTimeoutMs: 60000`. It does not mount `dsh-web-fetch-http` or select a fetch provider. The shared base makes only `web_search` a default for TUI, browser, and headless sessions. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows. The one-minute shipped budget covers an auxiliary DeepSeek Messages request plus server-side retrieval while leaving `dsh-tool-web`'s provider-neutral 30-second default unchanged for custom compositions.
|
||||
`apps/cli/config/base.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official` and `fetchProvider: http`, `dsh-web-search-deepseek`, `dsh-web-fetch-http`, and `dsh-tool-web` with `fetch: false` and `searchTimeoutMs: 60000`. The shared base therefore keeps only `web_search` visible unless a product preset enables fetch; the shipped Web `cordis`, `code`, and `standard` presets do so. Explicit provider ids keep selection independent of registration order and leave personal or `--config` overlays able to replace or disable the rows. The one-minute shipped budget covers an auxiliary DeepSeek Messages request plus server-side retrieval while leaving `dsh-tool-web`'s provider-neutral 30-second default unchanged for custom compositions. The [Web capability seam decision](../architecture/2026-06-24-web-capability-seam.md) owns the public-fetch security policy and Web preset default.
|
||||
|
||||
DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered.
|
||||
|
||||
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
|
||||
|
||||
The default mount does not create a Web-specific permission policy. `web_search` executes outside the shell/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped `workspace-write` default governs file mutations only; a restricted-network product stance requires a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
|
||||
The default mount does not create a Web-specific permission policy. `web_search` and enabled `web_fetch` calls execute outside the shell/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. The HTTP provider restricts fetches to validated public destinations, but it does not constrain public data egress. The shipped `workspace-write` default governs file mutations only; a restricted-network product stance requires a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,8 +30,8 @@ The default mount does not create a Web-specific permission policy. `web_search`
|
||||
|
||||
**Raise `dsh-tool-web`'s provider-neutral timeout.** Rejected because custom providers and deployments own different latency expectations; the shipped DeepSeek composition owns this deployment budget.
|
||||
|
||||
**Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-http` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay.
|
||||
**Enable fetch on every shared-base surface.** Rejected because the shared base serves products with different network postures. It mounts the public-only provider but keeps the tool opt-in; the shipped Web presets deliberately enable it, while another product can leave it hidden or add stricter network policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
Native model requests on every shipped surface carry only the `web_search` schema and search-only prompt guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The TUI/Web composition smokes pin the shared `web_search` roster and absence of `web_fetch`; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility.
|
||||
Native model requests on every shared-base surface carry the `web_search` schema and search guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The shipped Web `cordis`, `code`, and `standard` presets additionally expose `web_fetch` with public-address enforcement and no per-call approval. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. Composition smokes pin the shared search roster and per-preset fetch choices; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility.
|
||||
|
||||
@@ -10,13 +10,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-http`,也不选择抓取提供方。共享 base 只将 `web_search` 设为 TUI、浏览器与无头会话的默认工具。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。
|
||||
`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official` 与 `fetchProvider: http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-http`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。因此,共享 base 只会暴露 `web_search`,除非产品 preset 启用抓取;已交付的 Web `cordis`、`code` 与 `standard` preset 会启用抓取。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。[Web 能力 seam 决策](../architecture/2026-06-24-web-capability-seam.zh.md)负责公开抓取安全策略与 Web preset 默认值。
|
||||
|
||||
DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。
|
||||
|
||||
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
|
||||
|
||||
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有约定。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
|
||||
默认挂载不会创建 Web 专用权限策略。`web_search` 与已启用的 `web_fetch` 调用会在 bash/文件系统沙箱及审批 preset 之外执行,并遵循 `dsh-tool-web` 的现有约定。HTTP 提供方把抓取限制到已验证的公开目的地址,但不限制公开数据出站。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,8 +30,8 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据
|
||||
|
||||
**提高 `dsh-tool-web` 的提供方无关超时。** 不予采纳:自定义提供方和部署有各自不同的延迟预期;这一部署预算应归已交付的 DeepSeek 组合所有。
|
||||
|
||||
**同时启用搜索和抓取。** 不予采纳:默认启用 `web_fetch` 会允许模型自行选择任意 URL,执行匿名出站 HTTP(S) 抓取。搜索负责发现信息;接受更广泛抓取范围的部署可以在覆盖层中选择启用 `dsh-web-fetch-http`,并将 `dsh-tool-web` 的 `fetch` 选项设为 `true`。
|
||||
**在每个共享 base surface 上启用抓取。** 不予采纳:共享 base 服务于网络策略不同的产品。它会挂载仅限公网的提供方,但保持工具按需启用;已交付的 Web preset 会有意启用该工具,其他产品则可以继续隐藏它或添加更严格的网络策略。
|
||||
|
||||
## 后果
|
||||
|
||||
每个已交付界面的原生模型请求都只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。TUI/Web 组合冒烟测试固定了共享的 `web_search` 清单及不提供 `web_fetch` 这一事实;构建后组合配置的转储固定了已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。
|
||||
每个共享 base surface 的原生模型请求都会携带 `web_search` schema 与搜索指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。已交付的 Web `cordis`、`code` 与 `standard` preset 还会暴露 `web_fetch`,实施公开地址强制校验且无需逐次审批。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。组合冒烟测试会固定共享搜索清单与各 preset 的抓取选择;构建后组合配置的转储固定已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-21-headless-reasoning-progress.md
|
||||
2026-08-21-headless-reasoning-progress.md: 6c4a3574b63ef316fd456f24b473406054ed30f3
|
||||
2026-08-21-headless-reasoning-progress.zh.md: e19fffc33fb5a55432ba2b6cb50550a0cfbd00b8
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: headless streams provider reasoning to stderr
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-21-headless-reasoning-progress.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The one-shot headless runner waits for complete Agent quiescence before printing the final assistant text. Reasoning-capable providers already expose their reasoning as durable `assistant/chunk` events, but a long reasoned response leaves the terminal silent until the run completes. The final answer must remain the only stdout payload so command substitution and other consumers keep a stable result channel.
|
||||
|
||||
The earlier [direct core entry-point decision](../architecture/2026-08-09-headless-direct-core-entry-point.md) required empty stderr on every successful run. That clause prevents live reasoning progress and is superseded by this note; its transport, durability, and completion decisions remain unchanged.
|
||||
|
||||
## Decision
|
||||
|
||||
`headless-runner` observes the exact Session it creates after startup quiescence and before submitting the task. Once the owned interval opens with `turn/start`, each non-empty `assistant/chunk.reasoning-delta` is written immediately to stderr. A contiguous reasoning phase starts with `dsh: reasoning:` on its own line; deltas retain provider order without token-boundary decoration. Reasoning block boundaries and usage metadata keep that phase open; a later non-reasoning block or output delta, stream finish, new turn, or listener disposal terminates it with one newline when the provider supplied none.
|
||||
|
||||
This output is a transient projection of the existing durable Session event stream. The runner still derives final text and exit status from the flushed log rather than from progress-presentation state. The LLM adapter, agent loop, Session event types, persistence format, and SDK projections do not change.
|
||||
|
||||
Reasoning progress is not TTY-gated and has no separate flag. A redirected stderr stream and a supervisor receive the same provider-reported content as an attached terminal. A successful run without reasoning still writes nothing to stderr; terminal model and driver errors keep their existing `dsh:` diagnostics after any open reasoning phase is terminated.
|
||||
|
||||
## Verification
|
||||
|
||||
The package test holds the Agent active after a reasoning delta and observes stderr before idle, then pins newline ownership for provider-terminated and unterminated phases plus terminal errors. The owner-local product expectation drives the shipped headless profile through a reasoning-plus-tool round and pins both stderr and the persisted Session. Recorded-session replay reconstructs expected stderr from scalar and packed chunk rows, closes sections on packed text and tool-call output, and uses the raw run log before fixture path tokenization in record modes. Built-bin acceptance sends `reasoning_content` through the native DeepSeek SSE adapter and requires reasoning on stderr while stdout remains the final answer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Dump reasoning after quiescence.** Folding reasoning from the persisted log would preserve content but leave the terminal silent during the long-running interval that motivates the feature.
|
||||
|
||||
**Wrap the LLM stream.** Tapping `ctx.llm.stream()` would place a presentation concern in the request path and duplicate the authoritative chunks that the agent loop already appends to the Session.
|
||||
|
||||
**Print a spinner or periodic heartbeat.** A timer reports process liveness rather than provider progress, adds an interval policy, and still hides reasoning that the provider already supplies. Time before the first reasoning delta remains silent and can be addressed separately if providers buffer their first token.
|
||||
|
||||
**Enable output only on a TTY or explicit flag.** Headless runs under CI and supervisors need the same progress signal, while implicit TTY-dependent behavior makes redirected runs differ from interactive runs. Callers that do not want reasoning logs redirect stderr.
|
||||
|
||||
## Consequences
|
||||
|
||||
Reasoning-capable successful runs now write provider-reported content to stderr, so log collectors may retain substantially more and potentially sensitive model output. Stdout remains one final assistant result, text-only success keeps stderr empty, errors remain line-separated, and no new configuration or durable format is introduced. Silence before the provider emits its first non-empty reasoning delta remains an explicit limitation.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: headless 将提供方推理流式写入 stderr
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-21-headless-reasoning-progress.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次性 headless runner 会等待 Agent(智能体)完全停稳,再打印最终 assistant 文本。具备推理能力的提供方已经把推理作为持久化的 `assistant/chunk` 事件暴露,但耗时较长的推理响应会让终端在运行完成前始终保持静默。最终答案必须继续作为 stdout 中唯一的载荷,使命令替换和其他消费方保持稳定的结果通道。
|
||||
|
||||
此前的[直接使用核心服务入口决策](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md)要求每次成功运行都保持 stderr 为空。该条款会阻止实时推理进度,因此由本 Agent Note 取代;其中关于传输、持久性与完成状态的其他决策保持不变。
|
||||
|
||||
## 决策
|
||||
|
||||
`headless-runner` 在启动工作完全停稳后、提交任务前,观察其创建的精确 Session。自身持有的区间以 `turn/start` 打开后,每个非空的 `assistant/chunk.reasoning-delta` 都会立即写入 stderr。一段连续推理以独占一行的 `dsh: reasoning:` 开始;各分片保持提供方顺序,不添加 token 边界装饰。推理块边界与用量元数据会保持该段打开;之后出现非推理块或输出分片、流结束、新轮次或 listener dispose(资源释放)时,如果提供方没有输出末尾换行,runner 会用一个换行终止该段。
|
||||
|
||||
该输出是既有持久化会话事件流的瞬时投影。runner 仍从 flush 后的日志而不是进度呈现状态推导最终文本与退出状态。LLM(大语言模型)适配器、agent loop(智能体循环)、Session 事件类型、持久化格式与 SDK 投影均不改变。
|
||||
|
||||
推理进度不按 TTY 启用,也没有单独 flag。重定向的 stderr 流与监督进程会收到和已连接终端相同的提供方报告内容。没有推理内容的成功运行仍不会写入 stderr;终止态模型错误与驱动器错误继续在任何已打开推理段终止后输出既有的 `dsh:` 诊断。
|
||||
|
||||
## 验证
|
||||
|
||||
包测试在推理分片后保持 Agent 活跃,并在 idle 前观察 stderr;测试同时固定由提供方终止和未终止的推理段换行归属,以及终止态错误。产品自有期望通过包含推理与工具调用的轮次驱动随附 headless profile,并固定 stderr 与持久化 Session。录制会话回放从标量及压缩分片记录重建预期 stderr,在压缩文本或工具调用输出处关闭推理段,并在录制模式下于 fixture 路径标记化之前使用原始运行日志。构建后二进制验收通过原生 DeepSeek SSE(Server-Sent Events)适配器发送 `reasoning_content`,要求推理出现在 stderr,同时 stdout 仍只包含最终答案。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**完全停稳后再输出推理。** 从持久化日志折叠推理能够保留内容,但在导致本功能产生的长时间运行区间内,终端仍会保持静默。
|
||||
|
||||
**包装 LLM 流。** 截取 `ctx.llm.stream()` 会把呈现职责放入请求路径,并重复处理 agent loop 已经追加到 Session 的权威分片。
|
||||
|
||||
**打印 spinner 或周期性心跳。** 定时器报告的是进程存活状态,而不是提供方进度;它还会新增间隔策略,并继续隐藏提供方已经给出的推理。首个推理分片前的时间仍保持静默;如果提供方会缓冲首个 token,可以另行处理。
|
||||
|
||||
**仅在 TTY 或显式 flag 下启用输出。** CI 与监督进程中的 headless 运行需要相同的进度信号,而隐式依赖 TTY 会让重定向运行与交互式运行产生差异。不需要推理日志的调用方可以重定向 stderr。
|
||||
|
||||
## 后果
|
||||
|
||||
具备推理能力的成功运行会把提供方报告的内容写入 stderr,因此日志收集器可能保留明显更多且可能敏感的模型输出。stdout 仍只包含一个最终 assistant 结果,没有推理内容的成功运行保持 stderr 为空,错误继续与推理内容分行,并且本决策不引入新配置或持久化格式。提供方发出首个非空推理分片前保持静默,这是明确的限制。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-web-per-turn-token-usage.md
|
||||
2026-08-24-web-per-turn-token-usage.md: 91aab0f2c261e2141ee964c828e7209ba2b3f72f
|
||||
2026-08-24-web-per-turn-token-usage.zh.md: f9c424fa0f84b802e98280bea9eaf4038bf31f19
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Exact Web per-Turn token usage
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-24-web-per-turn-token-usage.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Web Chat exposes cumulative session token usage near the composer, but that value cannot explain the cost of one completed Turn. A paged history window may begin inside a Turn, retries may consume several model calls, streaming and final events may repeat one attempt's usage, and optional cache fields do not prove an exact total. Displaying a partial subtotal as Turn usage would make recorded provider facts look more complete than they are.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared `TokenUsage` value carries optional `totalTokens` for one model call. Adapters publish it only from an exact provider total or authoritative aggregate prompt and output counters. DeepSeek checks its prompt-plus-completion aggregate against any wire total, and pi-ai preserves its provided total.
|
||||
|
||||
Token-meter owns a browser-safe pure Turn-local fold over durable session events, shared with its retry-aware cumulative usage projection. `step/start` and `llm/retry-started` open actual attempts; a final assistant message replaces the same attempt's streaming sample; terminal failures, retries, and step boundaries close attempts without double counting. Every started attempt must close with safe non-negative integer usage and an exact total. Optional cache, reasoning, and route aggregates appear only when every contributing attempt reports them, and reasoning remains a subset of output.
|
||||
|
||||
Web Chat selects a Turn only when its loaded match window includes `turn/start`, passes that complete durable-event window to the token-meter fold, and renders the result. A complete, exact result appears through a local-state `DisclosureRow` above the existing actions; incomplete or contradictory evidence produces no row. Chat owns no token-accounting state machine.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Subtract neighboring cumulative session values.** Rejected because pagination, compaction, retry coverage, and projection completeness can make adjacent values incomparable; subtraction would infer data that no call reported.
|
||||
|
||||
**Publish historical per-Turn values through a new client session projection.** Rejected because the loaded per-Turn view already has the durable attempt events it needs, while a history-growing projection would add transport, persistence, and versioning costs. Reusing token-meter's pure fold keeps one accounting owner without adding another wire value.
|
||||
|
||||
**Show known buckets without an exact total.** Rejected because a lower-bound subtotal presented in a completed Turn footer is indistinguishable from a complete bill.
|
||||
|
||||
## Consequences
|
||||
|
||||
New provider records can expose exact per-Turn accounting without a new transport or persisted UI state. Older sessions and adapters without enough evidence simply omit the disclosure. Model routes disappear as a group when any billed attempt lacks attribution, while trustworthy token totals remain visible.
|
||||
|
||||
Focused adapter, token-meter fold/projection, component, pagination, and assembled Web replay tests pin total preservation, retry-attempt separation, fail-closed validation, optional-field omission, interaction, and full-window publication. The cumulative projection and exact Turn fold now share token-meter ownership; the projection remains a whole-log bucket view, while the fold alone makes the stricter exactness and completeness claim required by the disclosure.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Web 单轮次精确 token 用量
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-24-web-per-turn-token-usage.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web Chat 在编辑框附近显示会话累计 token 用量,但该值无法解释一个已完成轮次的消耗。分页历史窗口可能从轮次中间开始,重试可能消耗多次模型调用,流式事件与最终事件可能重复携带同一次 attempt 的用量,而可选 cache 字段也不能证明精确总量。将局部小计显示成轮次用量,会让已记录的提供方事实显得比实际更完整。
|
||||
|
||||
## Decision
|
||||
|
||||
共享 `TokenUsage` 值为一次模型调用携带可选的 `totalTokens`。适配器只从提供方精确总量,或权威的提示词与输出聚合计数发布该字段。DeepSeek 会将提示词加输出的聚合值与协议提供的总量核对,pi-ai 则保留其提供的总量。
|
||||
|
||||
token-meter 拥有一份可安全用于浏览器的纯轮次局部 fold,并与其具备重试感知能力的累计用量投影共享记账所有权。`step/start` 与 `llm/retry-started` 打开真实 attempt;最终 assistant 消息替换同一 attempt 的流式样本;终止失败、重试与步骤边界关闭 attempt,且不会重复计数。每个已开始的 attempt 都必须以安全的非负整数用量和精确总量关闭。只有每个参与聚合的 attempt 都报告时,才会显示可选的 cache、推理与路由聚合值;推理仍是输出的子集。
|
||||
|
||||
Web Chat 只选择已加载匹配窗口包含 `turn/start` 的 Turn,将该完整的持久事件窗口交给 token-meter fold,再渲染结果。完整且精确的结果通过现有 actions 上方、仅保留本地状态的 `DisclosureRow` 显示;证据不完整或矛盾时不显示该行。Chat 不拥有 token 记账状态机。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**对相邻的会话累计值做减法。** 不采用,因为分页、压缩、重试覆盖范围与投影完整性可能让相邻值无法比较;减法会推断任何调用都未报告的数据。
|
||||
|
||||
**通过新的客户端会话投影发布历史单轮次值。** 不采用,因为已加载的单轮次视图已经拥有所需的持久 attempt 事件,而随历史增长的投影会增加传输、持久化与版本成本。复用 token-meter 的纯 fold,可以在不新增 wire 值的前提下保持唯一记账所有方。
|
||||
|
||||
**缺少精确总量时仍显示已知 bucket。** 不采用,因为在已完成轮次 footer 中展示的下界小计与完整账单无法区分。
|
||||
|
||||
## Consequences
|
||||
|
||||
新的提供方记录无需新增传输接口或持久化 UI 状态,即可显示精确的单轮次记账。证据不足的旧会话与适配器只会省略 disclosure。任一计费 attempt 缺少归属时,模型路由会整体消失,可信 token 总量仍可显示。
|
||||
|
||||
定向的适配器、token-meter fold/投影、组件、分页与组装 Web 回放测试固定了总量保留、重试 attempt 分离、fail-closed 校验、可选字段省略、交互与完整窗口发布。累计投影与精确 Turn fold 现在同归 token-meter 所有;投影仍是完整日志的 bucket 视图,只有 fold 会作出 disclosure 所需的更严格精确性与完整性声明。
|
||||
@@ -504,6 +504,8 @@ jobs:
|
||||
shell: pwsh
|
||||
run: >-
|
||||
pnpm exec vitest run
|
||||
--no-file-parallelism
|
||||
--testTimeout 30000
|
||||
packages/shell/tool-pwsh/tests/loader.spec.ts
|
||||
packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
|
||||
packages/workflow/tool-ralph/tests/integration.spec.ts
|
||||
|
||||
@@ -67,6 +67,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
|
||||
| [`immer`](https://github.com/immerjs/immer) | MIT |
|
||||
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
|
||||
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
|
||||
| [`katex`](https://github.com/KaTeX/KaTeX) | MIT |
|
||||
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
|
||||
@@ -97,6 +98,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
|
||||
| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
|
||||
| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 |
|
||||
| [`undici`](https://github.com/nodejs/undici) | MIT |
|
||||
| [`use-sync-external-store`](https://github.com/facebook/react) | MIT |
|
||||
| [`ws`](https://github.com/websockets/ws) | MIT |
|
||||
| [`yaml`](https://github.com/eemeli/yaml) | ISC |
|
||||
|
||||
@@ -158,6 +158,8 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_web
|
||||
plugin_dsh_base_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
|
||||
cfg --> plugin_dsh_base_web_search_deepseek
|
||||
plugin_dsh_base_web_fetch_http["web-fetch-http<br/>@deepseek-ai/dsh-web-fetch-http"]
|
||||
cfg --> plugin_dsh_base_web_fetch_http
|
||||
plugin_dsh_base_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
|
||||
cfg --> plugin_dsh_base_tool_web
|
||||
plugin_dsh_base_tools["tools<br/>@deepseek-ai/dsh-tools"]
|
||||
@@ -249,6 +251,7 @@ flowchart LR
|
||||
| `repeat-tool-reminder` | `@deepseek-ai/dsh-repeat-tool-reminder` |
|
||||
| `web` | `@deepseek-ai/dsh-web` |
|
||||
| `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
|
||||
| `web-fetch-http` | `@deepseek-ai/dsh-web-fetch-http` |
|
||||
| `tool-web` | `@deepseek-ai/dsh-tool-web` |
|
||||
| `tools` | `@deepseek-ai/dsh-tools` |
|
||||
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: eb33816b3ac859e8173e62635f74abbe13fb8462
|
||||
README.zh.md: ed84927aa4f211926fd32750385268dc5153b3a9
|
||||
README.md: fe8d6ef0bb296f0807de4a3ec2756016bbb510c2
|
||||
README.zh.md: e8c353f33bc9760fd6da74af33a85111cf9012aa
|
||||
|
||||
@@ -30,7 +30,7 @@ The shipped apps own these command lines:
|
||||
| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol |
|
||||
| `acp` | no options; stdio carries Agent Client Protocol |
|
||||
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client, and opens no listening port.
|
||||
|
||||
Inspect the composed tree without booting it:
|
||||
|
||||
@@ -89,7 +89,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission
|
||||
|
||||
## Shared deployment behavior
|
||||
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
|
||||
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting.
|
||||
|
||||
Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 |
|
||||
| `acp` | 无选项;stdio 携带 Agent Client Protocol |
|
||||
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。
|
||||
|
||||
可在不启动的情况下检查组合出的配置树:
|
||||
|
||||
@@ -89,7 +89,7 @@ dsh web --help
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
|
||||
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。
|
||||
|
||||
会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。
|
||||
|
||||
|
||||
@@ -560,8 +560,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
it('runs the headless profile through its app-owned task positional', async () => {
|
||||
const apiKey = 'built-dsh-headless-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['success'],
|
||||
sequence: ['reasoning_success'],
|
||||
apiKey,
|
||||
reasoningText: 'Inspecting the published entry.',
|
||||
successText: 'published headless profile reached the mock',
|
||||
})
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
|
||||
@@ -574,7 +575,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published headless profile reached the mock')
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
dsh: reasoning:
|
||||
Inspecting the task before the tool call.
|
||||
+9
-6
@@ -12,14 +12,17 @@
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}}
|
||||
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Inspecting the task before the tool call."}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17,18,19,20],"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
|
||||
@@ -28,6 +31,6 @@
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}}
|
||||
{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[27,28,29,30,31],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -41,6 +41,7 @@ const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-de
|
||||
const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.cordis.yml', import.meta.url))
|
||||
const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url))
|
||||
const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl')
|
||||
const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt')
|
||||
const headlessFailureExpected = join(goldensDir, 'headless-profile', 'stderr.expected.txt')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
@@ -223,7 +224,8 @@ describe('headless stream-json snapshots', () => {
|
||||
})
|
||||
|
||||
expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
|
||||
expect(result.stderr).toBe('')
|
||||
if (refreshing) await writeFile(headlessReasoningExpected, result.stderr)
|
||||
expect(result.stderr).toBe(await readFile(headlessReasoningExpected, 'utf8'))
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('prints a terminal model failure through the product headless profile command', async () => {
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
|
||||
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search',
|
||||
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
|
||||
'workflow', 'write',
|
||||
])
|
||||
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
|
||||
|
||||
@@ -29,9 +29,10 @@ const FILE_REFERENCE_PROMPT = fileURLToPath(new URL(
|
||||
* The catalog the shipped Web composition puts in front of the model, minus the
|
||||
* ripgrep-dependent pair below. The absences are deliberate, not incidental
|
||||
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
|
||||
* sandbox row confines, `web_fetch` chooses its own request target, and
|
||||
* `mcp_*` servers spawn outside `ctx.shell`. The composition Agent Note owns the
|
||||
* rationale and its sources.
|
||||
* sandbox row confines, and `mcp_*` servers spawn outside `ctx.shell`.
|
||||
* `web_fetch` is present because public-address enforcement and one-shot
|
||||
* approval now confine its model-selected request target. The composition
|
||||
* Agent Note owns the rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
'ask_user_question',
|
||||
@@ -54,6 +55,7 @@ const EXPECTED_TOOLS = [
|
||||
'subagent_fork',
|
||||
'todo_write',
|
||||
'update_goal',
|
||||
'web_fetch',
|
||||
'web_search',
|
||||
'workflow',
|
||||
'write',
|
||||
|
||||
@@ -469,6 +469,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
.filter(name => name === 'web_search' || name === 'web_fetch'))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
]
|
||||
`)
|
||||
|
||||
@@ -27,6 +27,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// Two goldens for the same message: parked mid-turn, then settled.
|
||||
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The recording must carry text in the SAME assistant message as the tool
|
||||
@@ -156,7 +157,34 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('shows exact completed-Turn usage and expands its available facts', async () => {
|
||||
await launch()
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-usage-expanded'))
|
||||
const { settled } = await sendPrompt(120_000)
|
||||
await settled
|
||||
|
||||
const disclosure = page.getByRole('button', { name: /Turn usage/ })
|
||||
await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await page.getByText('15.8K tok · Cache hit 49.7%', { exact: true }).count()).toBe(1)
|
||||
|
||||
await disclosure.click()
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await page.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('7,891 tok', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('7,808 tok', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('15,811 tok', { exact: true }).count()).toBe(1)
|
||||
|
||||
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'session.jsonl', 'settled.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('web e2e: blank New Session folding quota', () => {
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const workspaceTitle = basename(scaffold.workspaceCwd)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 9546136837b623348547a41feed9a9709ed2eedd
|
||||
config-catalog.zh.md: 684bafefd588682dcf157ce00d23ffc2dc07f5a5
|
||||
config-catalog.md: 316b7a6ae74c38268ce1c4ff41847a8e3392485c
|
||||
config-catalog.zh.md: 373c1168f571f6aea97453880a31ab4d9a774e02
|
||||
|
||||
@@ -703,7 +703,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts)
|
||||
Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-hooks-claude-code"></a>
|
||||
|
||||
@@ -2833,7 +2833,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts)
|
||||
Source: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent"></a>
|
||||
|
||||
@@ -3156,8 +3156,6 @@ Requires: `web`
|
||||
```ts config-catalog
|
||||
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
|
||||
export interface Config {
|
||||
/** Maximum accepted request URL length. */
|
||||
maxUrlLength?: number
|
||||
/** Maximum response body size in bytes. */
|
||||
maxResponseBytes?: number
|
||||
/** Maximum decoded body length in characters. */
|
||||
|
||||
@@ -705,7 +705,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts)
|
||||
来源:[`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-hooks-claude-code"></a>
|
||||
|
||||
@@ -2835,7 +2835,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/fs/tool-str-replace-editor/src/index.ts:497`](../packages/fs/tool-str-replace-editor/src/index.ts)
|
||||
来源:[`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-subagent"></a>
|
||||
|
||||
@@ -3158,8 +3158,6 @@ export interface Config {
|
||||
```ts config-catalog
|
||||
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
|
||||
export interface Config {
|
||||
/** Maximum accepted request URL length. */
|
||||
maxUrlLength?: number
|
||||
/** Maximum response body size in bytes. */
|
||||
maxResponseBytes?: number
|
||||
/** Maximum decoded body length in characters. */
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: de2a94abb5e4d16433eae71e34e329fcf0042ede
|
||||
event-producer-consumer.zh.md: 7a9e825750213b2d0a67d9c022bffe031194c8ba
|
||||
event-producer-consumer.md: baeddb7b0171b4347fa1748e0adf87df5c15e4d0
|
||||
event-producer-consumer.zh.md: baee416d8c0bf1b4102f839cdcd98654d0acd63e
|
||||
|
||||
@@ -47,7 +47,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: cc8eaf8b49dc95568d34a4d57e9cbff8d30656c1
|
||||
module-graph.zh.md: 73ba9081455a265194aae943fb96efc0ec95d38f
|
||||
module-graph.md: b407080d634c0e70a00f494c686f55f85998046e
|
||||
module-graph.zh.md: 542ea6be5a1f1f41c5b39e4e83b2c49c97439332
|
||||
|
||||
@@ -741,6 +741,7 @@ flowchart TD
|
||||
pkg_token_meter --> pkg_compaction
|
||||
pkg_token_meter --> pkg_invariants
|
||||
pkg_token_meter --> pkg_llm
|
||||
pkg_token_meter --> pkg_llm_retry
|
||||
pkg_token_meter --> pkg_session
|
||||
pkg_token_meter --> pkg_session_projection
|
||||
pkg_agent_loop --> pkg_agent
|
||||
@@ -1778,7 +1779,7 @@ flowchart TD
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
|
||||
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -743,6 +743,7 @@ flowchart TD
|
||||
pkg_token_meter --> pkg_compaction
|
||||
pkg_token_meter --> pkg_invariants
|
||||
pkg_token_meter --> pkg_llm
|
||||
pkg_token_meter --> pkg_llm_retry
|
||||
pkg_token_meter --> pkg_session
|
||||
pkg_token_meter --> pkg_session_projection
|
||||
pkg_agent_loop --> pkg_agent
|
||||
@@ -1780,7 +1781,7 @@ flowchart TD
|
||||
| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
|
||||
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/approval.md
|
||||
approval.md: 7b12e7f766555fda09b5b2ac405129b8bfe17daf
|
||||
approval.zh.md: 7596f28d51ef6dfd4e883eaff8c155111e1d2f1c
|
||||
approval.md: d9f1169b52e427cd37e7bc54fa37da59d48aecce
|
||||
approval.zh.md: abc2361db3d84517c7e7497cfb39b4548160c259
|
||||
|
||||
@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
## Per-session policy
|
||||
|
||||
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
|
||||
`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. Consumers read it with `ctx.approval.effectivePolicy(session)`; `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
## 按会话策略
|
||||
|
||||
`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
|
||||
`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。消费方通过 `ctx.approval.effectivePolicy(session)` 读取;`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
|
||||
llm-streaming.md: bdc830a5d387cde6967575551ec9b0a9b2626f46
|
||||
llm-streaming.zh.md: b602336bc06cd88a2634f5259eff117da3dcd986
|
||||
llm-streaming.md: 29efabd2b01659bdf2cc798ceadb4bb495e1731e
|
||||
llm-streaming.zh.md: 21ad56e526b9a507644b436b41ad063c5310b2ce
|
||||
|
||||
@@ -278,7 +278,7 @@ interface AppIdentity {
|
||||
|
||||
## `TokenUsage`
|
||||
|
||||
Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again.
|
||||
Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. Optional `totalTokens` is an exact aggregate prompt-plus-output count preserved from the provider or reconstructed from authoritative aggregate counters; adapters omit it when unavailable or inconsistent. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -292,6 +292,14 @@ Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached in
|
||||
interface TokenUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
/**
|
||||
* Exact full-call total including aggregate prompt and output tokens.
|
||||
*
|
||||
* Adapters preserve a provider total or derive it from authoritative
|
||||
* aggregate prompt/output counters; they omit it when unavailable or
|
||||
* inconsistent.
|
||||
*/
|
||||
totalTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
|
||||
@@ -282,7 +282,7 @@ interface AppIdentity {
|
||||
|
||||
## `TokenUsage`
|
||||
|
||||
逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。
|
||||
逐调用 token 记账。各计数**互不重叠**:`inputTokens` 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 `prompt_tokens`),适配器会再将其扣除。可选的 `totalTokens` 是精确的提示词与输出聚合计数,由适配器保留提供方原值或从权威聚合计数重建;不可用或不一致时省略。`reasoningTokens` 存在时只是信息性细节,已经包含在 `outputTokens` 中;汇总时不得重复相加。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -296,6 +296,14 @@ interface AppIdentity {
|
||||
interface TokenUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
/**
|
||||
* Exact full-call total including aggregate prompt and output tokens.
|
||||
*
|
||||
* Adapters preserve a provider total or derive it from authoritative
|
||||
* aggregate prompt/output counters; they omit it when unavailable or
|
||||
* inconsistent.
|
||||
*/
|
||||
totalTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web.md
|
||||
web.md: 4acab9273b3b2753409c680bd41e93fb3a627843
|
||||
web.zh.md: 0133b78d0080ab16c14ac7f42628cc705bb4bc9c
|
||||
web.md: fe6f1ca357eec19f55848ffbe54ed339eb638924
|
||||
web.zh.md: bef3803abcd9c23582ee94479c89a05c7e3943ef
|
||||
|
||||
@@ -124,13 +124,19 @@ A provider's `available(): boolean` is a cheap LOCAL check (credential presence,
|
||||
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
|
||||
|
||||
## Fetch network policy
|
||||
|
||||
The shipped Cordis, Code, and Standard presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. File sandbox presets do not govern Web network access. A deployment that needs confirmation must add a `tools/pre-execute` policy or disable fetch.
|
||||
|
||||
The HTTP provider resolves each actual request, rejects non-public answers including private IPv4 reached through the active DNS64 prefix, pins the validated address set, and repeats enforcement for each same-origin redirect. A cross-origin redirect requires a new tool call and fresh public-address validation. These checks prevent SSRF access to non-public destinations but do not stop a model from sending data to a public URL.
|
||||
|
||||
## Errors
|
||||
|
||||
`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by the shared `WebRuntime` contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmRuntime`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-http` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`.
|
||||
|
||||
## The service
|
||||
|
||||
`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, caps redirects, bytes, characters, and time, revalidates every same-origin redirect hop, and decodes the body; the tool owns presentation. The local backend does not block private-network targets; do not enable `web_fetch` where it can reach sensitive internal ones.
|
||||
`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, resolves each hostname once, rejects any answer set containing a non-public IPv4 or IPv6 destination or an active-prefix NAT64 translation to non-public IPv4, pins the request connection to the validated addresses, repeats those checks for every same-origin redirect hop, caps redirects, bytes, characters, and time, and decodes the body; the tool owns presentation.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -124,13 +124,19 @@ type WebFetchBody =
|
||||
|
||||
选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。
|
||||
|
||||
## 抓取网络策略
|
||||
|
||||
已交付的 Cordis、Code 与 Standard preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。文件 sandbox preset 不管辖 Web 网络访问。需要确认步骤的部署必须添加 `tools/pre-execute` 策略或禁用抓取。
|
||||
|
||||
HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前缀抵达私有 IPv4 在内的非公开结果,固定已验证的地址集合,并在每次同源重定向时重复强制执行。跨源重定向需要新的工具调用和新的公开地址校验。这些检查会阻止通过 SSRF 访问非公开目的地址,但不会阻止模型把数据发送到公开 URL。
|
||||
|
||||
## 错误
|
||||
|
||||
`WebError extends HarnessError`([core.md](core.zh.md) 错误分类体系),带有 `code: string`(开放式,与其他 seam 的错误一致——`LlmError`、`SubagentError`),而非封闭联合类型:提供方可以在不修改 `dsh-web` 的情况下抛出自己的错误代码,消费方必须容忍未知错误代码。错误代码按所有者划分。共享的 `WebRuntime` 约定会抛出与 seam 无关的错误代码:`WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`、`WEB_PROVIDER_AMBIGUOUS`、`WEB_DUPLICATE_PROVIDER`(注册时的编程错误,类似 `LlmRuntime` 的 `DUPLICATE_ADAPTER`)、`WEB_ABORTED`,以及 `WEB_PROVIDER_ERROR`(提供方自身故障经 seam 暴露时使用的兜底代码,包括 DNS、连接被拒绝、TLS 等网络或传输故障)。抓取传输层错误代码由 `dsh-web-fetch-http` 实现拥有,不同的抓取后端无需抛出它们:`WEB_INVALID_URL`、`WEB_BLOCKED_URL`、`WEB_REDIRECT_BLOCKED`、`WEB_FETCH_TOO_LARGE`、`WEB_FETCH_TIMEOUT`、`WEB_UNSUPPORTED_CONTENT_TYPE`。
|
||||
|
||||
## 服务
|
||||
|
||||
`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、限制重定向次数、字节数、字符数和时间、对每一次同源重定向跳转重新进行安全校验,并解码正文;展示由工具负责。本地后端不会拦截私有网络目标;在能够触及敏感内部目标的环境中,禁止启用 `web_fetch`。
|
||||
`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、对每个 hostname 只解析一次、拒绝包含任一非公开 IPv4/IPv6 目的地址或经当前前缀转换到非公开 IPv4 的 NAT64 地址的解析结果、把请求连接固定到已验证地址、对每一次同源重定向跳转重复这些校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
|
||||
tool-catalog.md: 0cd8560a6851f0195e2272d1bd3b0bec2c171ac4
|
||||
tool-catalog.zh.md: cb225bc11afa6a6b022f2c7c104d4e1286f89260
|
||||
tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622
|
||||
tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819
|
||||
|
||||
+49
-13
@@ -566,6 +566,7 @@ Custom editing tool for viewing, creating and editing files
|
||||
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
|
||||
* The `create` command cannot be used if the specified `path` already exists as a file
|
||||
* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
|
||||
* A null placeholder for a parameter unused by the selected command is treated as omitted. Required parameters still need values; omit `str_replace.new_str` rather than setting it to null when deleting a match
|
||||
|
||||
Notes for using the `str_replace` command:
|
||||
* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
|
||||
@@ -591,27 +592,62 @@ Notes for using the `str_replace` command:
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `create` command, with the content of the file to be created."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"type": "integer",
|
||||
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"type": "string",
|
||||
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"type": "array",
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+49
-13
@@ -571,6 +571,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
|
||||
* 如果 `path` 是文件,`view` 会显示应用 `cat -n` 后的结果。如果 `path` 是目录,`view` 会列出最多向下 2 层的非隐藏文件和目录
|
||||
* 如果指定的 `create` 命令目标 `path` 已作为文件存在,则不能使用该命令
|
||||
* 如果 `command` 产生较长输出,输出会被截断并标记为 `<response clipped>`
|
||||
* 当前命令不使用某个参数时,值为 `null` 的占位参数视为未提供。必填参数仍须提供值;删除匹配内容时应省略 `str_replace.new_str`,而不是将其设为 `null`
|
||||
|
||||
使用 `str_replace` 命令时请注意:
|
||||
|
||||
@@ -597,27 +598,62 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
|
||||
"description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`."
|
||||
},
|
||||
"file_text": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `create` command, with the content of the file to be created."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `create` command, with the content of the file to be created. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"insert_line": {
|
||||
"type": "integer",
|
||||
"description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required integer parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"new_str": {
|
||||
"type": "string",
|
||||
"description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional string parameter of `str_replace` command containing the new string (if omitted, no string will be added). Required string parameter of `insert` command containing the string to insert. A null placeholder is accepted only by commands that do not use this parameter."
|
||||
},
|
||||
"old_str": {
|
||||
"type": "string",
|
||||
"description": "Required parameter of `str_replace` command containing the string in `path` to replace."
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Required string parameter of `str_replace` command containing the string in `path` to replace. A null placeholder is treated as omitted by commands that do not use this parameter."
|
||||
},
|
||||
"view_range": {
|
||||
"type": "array",
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"entry": [
|
||||
"scripts/**/*.mjs",
|
||||
"scripts/**/*.cjs",
|
||||
"snapshots/**/*.mjs",
|
||||
"scripts/types/client-build-environment/index.d.ts"
|
||||
],
|
||||
"ignoreUnresolved": [
|
||||
@@ -34,7 +35,8 @@
|
||||
"project": [
|
||||
"scripts/**/*.ts",
|
||||
"scripts/**/*.mjs",
|
||||
"scripts/**/*.cjs"
|
||||
"scripts/**/*.cjs",
|
||||
"snapshots/**/*.mjs"
|
||||
]
|
||||
},
|
||||
"packages/host/directory-picker-auto": {
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-fetch-http": "workspace:^",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -408,24 +408,30 @@
|
||||
thresholds: [3, 5, 8]
|
||||
argumentsPreviewChars: 500
|
||||
|
||||
# Every mode enables the stable model-facing web_search tool. DeepSeek search
|
||||
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
|
||||
# chat, at each search; its Messages endpoint is separate from the
|
||||
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
|
||||
# disabled and no fetch provider is mounted: that provider defers SSRF
|
||||
# protection and the model would choose the request target. Search is a full
|
||||
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
|
||||
# route gets 60s while the provider-neutral tool default remains 30s.
|
||||
# Every mode enables the stable model-facing web_search tool. The Web app's
|
||||
# per-agent presets additionally enable web_fetch; other products opt in by
|
||||
# overriding tool-web. DeepSeek search resolves the same DEEPSEEK_API_KEY
|
||||
# credential the Models page manages for chat, at each search; its Messages
|
||||
# endpoint is separate from the chat-completions endpoint, so it takes its own
|
||||
# base-URL override. Anonymous fetch accepts only public HTTP(S) destinations,
|
||||
# resolves and validates every destination, and pins every actual connection.
|
||||
# Search is a full auxiliary model request with server-side retrieval, so this
|
||||
# shipped DeepSeek route gets 60s while the provider-neutral tool default
|
||||
# remains 30s.
|
||||
- id: web
|
||||
name: '@deepseek-ai/dsh-web'
|
||||
config:
|
||||
searchProvider: deepseek-official
|
||||
fetchProvider: http
|
||||
|
||||
- id: web-search-deepseek
|
||||
name: '@deepseek-ai/dsh-web-search-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
|
||||
- id: web-fetch-http
|
||||
name: '@deepseek-ai/dsh-web-fetch-http'
|
||||
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:^",
|
||||
"@deepseek-ai/dsh-web": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-fetch-http": "workspace:^",
|
||||
"@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-instructions": "workspace:^"
|
||||
|
||||
@@ -41,8 +41,12 @@ describe('dsh-base bundle', () => {
|
||||
})
|
||||
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
|
||||
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
|
||||
expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' })
|
||||
expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined()
|
||||
expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: false })
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
|
||||
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
|
||||
expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http')
|
||||
})
|
||||
|
||||
it('gates each shell stack by platform with a symmetric disabled expression', () => {
|
||||
|
||||
@@ -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/bundle/headless/README.md
|
||||
README.md: 22b4ac8ecbbaabc1d5268230ea99a5d3a89aff14
|
||||
README.zh.md: a57e29dc947c0c368165af0ad4342a748711500b
|
||||
README.md: 373e50c515ef45d09c32e7dd1b011f149d921250
|
||||
README.zh.md: 443945ced7e5e41899e91e8c169918f4e186de67
|
||||
|
||||
@@ -4,7 +4,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it inherits the base's disabled module-HMR policy, supplies the coding persona and tool mode, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. Each non-empty provider reasoning delta from that Agent is written to stderr as it arrives under a `dsh: reasoning:` heading; consecutive deltas remain one section, and the runner terminates the section before later output when the provider supplied no trailing newline. It then flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; a successful run with no reasoning keeps stderr empty. The process opens no listening port.
|
||||
|
||||
The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,4 +19,6 @@ None; the runner adds nothing to the request prefix.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
|
||||
- **No pre-token heartbeat** — stderr remains silent until the provider emits a non-empty reasoning delta; a provider that delays its first streamed token exposes no earlier progress signal.
|
||||
- **Reasoning enters stderr logs** — redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when that content must not be collected.
|
||||
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.zh.md) 之上:继承 base 默认禁用模块 HMR(热模块替换)的策略,提供编码 persona 和工具模式,将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.zh.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.zh.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终结束原因为 `error` 时,还会将 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.zh.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。该 Agent 每次产生非空的提供方推理分片时,runner 都会在 `dsh: reasoning:` 标题下将其即时写入 stderr;连续分片保留在同一段中,提供方没有输出末尾换行时,runner 会在后续输出前终止该段。随后,它对 Session 执行 flush,再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,并经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.zh.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终结束原因为 `error` 时,还会将 code 与 message 写入 stderr;没有推理内容的成功运行会保持 stderr 为空。进程不会打开监听端口。
|
||||
|
||||
任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,4 +19,6 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
|
||||
- **首个 token 前没有心跳**:在提供方发出非空推理分片前,stderr 保持静默;如果提供方延迟首个流式 token,系统不会提供更早的进度信号。
|
||||
- **推理会进入 stderr 日志**:重定向与监督进程可能保留明显更多且可能敏感的模型输出;不得收集该内容时,应将 stderr 送往受控目标。
|
||||
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
|
||||
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
||||
* creates one Agent through the core registry, drives the task to quiescence,
|
||||
* flushes its Session, prints the final assistant text, and exits.
|
||||
* streams provider reasoning to stderr, flushes its Session, prints the final
|
||||
* assistant text to stdout, and exits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-headless
|
||||
*/
|
||||
@@ -11,9 +12,9 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the loader Context merge for the settlement await
|
||||
@@ -81,6 +82,71 @@ function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcom
|
||||
return { text, reason }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project provider-reported reasoning from one owned run to stderr as it is
|
||||
* appended, while keeping final outcome derivation on the durable log.
|
||||
* @param ctx - plugin context carrying the Session event feed.
|
||||
* @param agent - the exact Agent whose reasoning belongs to this invocation.
|
||||
* @param stderr - progress output sink.
|
||||
* @returns a disposer that also terminates an unterminated reasoning line.
|
||||
*/
|
||||
function streamReasoning(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
stderr: HeadlessIo['stderr'],
|
||||
): () => void {
|
||||
let started = false
|
||||
let open = false
|
||||
let endsWithNewline = true
|
||||
const close = (): void => {
|
||||
if (!open) return
|
||||
if (!endsWithNewline) stderr.write('\n')
|
||||
open = false
|
||||
endsWithNewline = true
|
||||
}
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'turn/start') {
|
||||
close()
|
||||
started = true
|
||||
return
|
||||
}
|
||||
if (!started || event.type !== 'assistant/chunk') return
|
||||
const chunk = event.data.chunk
|
||||
switch (chunk.type) {
|
||||
case 'reasoning-delta':
|
||||
if (chunk.text === '') return
|
||||
if (!open) {
|
||||
stderr.write('dsh: reasoning:\n')
|
||||
open = true
|
||||
}
|
||||
stderr.write(chunk.text)
|
||||
endsWithNewline = chunk.text.endsWith('\n')
|
||||
return
|
||||
case 'block-start':
|
||||
if (chunk.blockType !== 'reasoning') close()
|
||||
return
|
||||
case 'block-end':
|
||||
if (chunk.block.type !== 'reasoning') close()
|
||||
return
|
||||
case 'usage':
|
||||
return
|
||||
case 'text-delta':
|
||||
case 'tool-call-delta':
|
||||
case 'finish':
|
||||
close()
|
||||
return
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
return assertNever(chunk, 'headless reasoning stream')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
dispose()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Report an unexpected direct-driver failure and request a failing exit. */
|
||||
function fail(io: HeadlessIo, error: unknown): void {
|
||||
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
@@ -119,11 +185,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
})
|
||||
await agent.whenIdle()
|
||||
const firstSeq = agent.session.seq
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: task }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
const stopReasoning = streamReasoning(ctx, agent, io.stderr)
|
||||
try {
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: task }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
} finally {
|
||||
stopReasoning()
|
||||
}
|
||||
await sessions.flush(agent.session)
|
||||
const outcome = summarize(agent.session.events, firstSeq)
|
||||
io.stdout.write(outcome.text + '\n')
|
||||
|
||||
@@ -14,10 +14,10 @@ export const name = 'headless-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the runner is a one-shot driver over the API carrier
|
||||
* whose observable contract (final text on stdout, exit code by turn-end
|
||||
* reason) is process-level and owned by the launcher e2e; it registers
|
||||
* nothing and holds no mutable relation to audit inside the tree.
|
||||
* No runtime invariant: the runner's observable contract (provider reasoning
|
||||
* on stderr, final text on stdout, exit code by turn-end reason) is
|
||||
* process-level and owned by the launcher e2e; it registers nothing and holds
|
||||
* no mutable relation to audit inside the tree.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface HeadlessStartupValues {
|
||||
function headlessCommand(): Command {
|
||||
return new Command()
|
||||
.name('dsh --profile headless')
|
||||
.description('Answer one task, print the final assistant message, and exit.')
|
||||
.description('Answer one task, stream reasoning to stderr, print the final assistant message, and exit.')
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.argument('[task...]', 'the task text; multiple words are joined by spaces')
|
||||
.addHelpText('after', `
|
||||
|
||||
@@ -50,9 +50,13 @@ function appendTurn(
|
||||
/** Mount the real registries around a small scripted Agent factory. */
|
||||
async function bench(script: Script): Promise<{
|
||||
ctx: Context
|
||||
output(): { out: string; err: string; order: string[] }
|
||||
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
let out = ''
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' })
|
||||
@@ -91,10 +95,8 @@ async function bench(script: Script): Promise<{
|
||||
})
|
||||
return {
|
||||
ctx,
|
||||
output: () => ({ out, err, order: [...order] }),
|
||||
run: async () => {
|
||||
let out = ''
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
@@ -143,6 +145,110 @@ describe('headless runner', () => {
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('streams reasoning before the Agent becomes idle and terminates its stderr line', async () => {
|
||||
const reasoningAppended = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const test = await bench({
|
||||
async afterPrompt(session, message) {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: '' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'checking the workspace' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: ' safely\n' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 1, blockType: 'reasoning' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 1, text: 'second pass\n' },
|
||||
})
|
||||
reasoningAppended.resolve(undefined)
|
||||
await release.promise
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-start', index: 2, blockType: 'text' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 2, text: 'done' },
|
||||
})
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } },
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: { provider: 'test-provider', model: 'test-model' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
},
|
||||
})
|
||||
const running = test.run()
|
||||
await reasoningAppended.promise
|
||||
const other = test.ctx.sessions.create()
|
||||
other.append('turn/start', { turn: 1 })
|
||||
other.append('step/start', { turn: 1, step: 1 })
|
||||
other.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'other session' },
|
||||
})
|
||||
const streamed = test.output()
|
||||
release.resolve(undefined)
|
||||
const result = await running
|
||||
expect(streamed).toEqual({
|
||||
out: '',
|
||||
err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
|
||||
order: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
code: 0,
|
||||
out: 'done\n',
|
||||
err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
|
||||
order: ['flush', 'exit'],
|
||||
})
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exits 1 when the final turn does not complete', async () => {
|
||||
const test = await bench({
|
||||
afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
|
||||
@@ -172,6 +278,32 @@ describe('headless runner', () => {
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('separates an unterminated reasoning prefix from the terminal model failure', async () => {
|
||||
const test = await bench({
|
||||
afterPrompt(session, message) {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', message, { surfaceOp: 'append' })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'reasoning-delta', index: 0, text: 'trying recovery' },
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
|
||||
})
|
||||
},
|
||||
})
|
||||
expect(await test.run()).toMatchObject({
|
||||
code: 1,
|
||||
out: '\n',
|
||||
err: 'dsh: reasoning:\ntrying recovery\ndsh: SERVER: provider unavailable\n',
|
||||
})
|
||||
await test.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exits 1 when the owned interval contains no turn', async () => {
|
||||
const test = await bench({ afterPrompt: () => {} })
|
||||
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('headless command-line provider', () => {
|
||||
it('prints its own help and leaves the runner pending', async () => {
|
||||
const { task, observed } = await bootStartup(['--help'])
|
||||
expect(observed.out).toContain('dsh --profile headless')
|
||||
expect(observed.out).toContain('stream reasoning to stderr')
|
||||
expect(task).toBeUndefined()
|
||||
expect(observed.runnerConfig).toBeUndefined()
|
||||
expect(observed.exits).toEqual([0])
|
||||
|
||||
@@ -53,12 +53,12 @@ function styleInjectionModule(
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline: browser-safe contracts
|
||||
* with no runtime identity to share (no Symbol/instanceof/singleton state).
|
||||
* Contract layers and pure folds a client bundle may inline: browser-safe
|
||||
* values with no runtime identity to share (no Symbol/instanceof/singleton state).
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
|
||||
README.md: ef9dc65de0d6b990fd0066c387518dc932bd4d2e
|
||||
README.zh.md: c4de06b18077485d7d65734b9bb38ff7745a4d67
|
||||
README.md: cc79de10289069ef94105397bd77a5194b4e6808
|
||||
README.zh.md: 3d4eb91492a497ff4544bd6378ae212810342c64
|
||||
|
||||
@@ -19,3 +19,4 @@ None; Chat presentation does not assemble or mutate provider requests.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The view reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page.
|
||||
- **Per-Turn token usage is fail-closed** — a completed Turn shows its disclosure only when the loaded window includes `turn/start` and every started model attempt has safe, exact usage. Missing buckets are omitted, and incomplete or contradictory accounting hides the whole disclosure.
|
||||
|
||||
@@ -19,3 +19,4 @@ Chat 会为非空的初始或恢复请求、显式序列起点,或 system 字
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **视图只反映已加载的 Session 窗口**——只有 Session Controller 加载前一页 event 后,更早的 transcript node 才会出现。
|
||||
- **单轮次 token 用量采用 fail-closed 方式**——只有已加载窗口包含 `turn/start`,且每个已开始的模型 attempt 都具有安全、精确的用量时,已完成轮次才显示 disclosure。缺失的 bucket 会被省略,记账不完整或矛盾时则隐藏整条 disclosure。
|
||||
|
||||
@@ -13,7 +13,6 @@ import { formatRunDuration } from './message-chrome.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
const MAX_PAGING_ANCHOR_PROBES = 64
|
||||
|
||||
/** Active column host when present; otherwise the view-local scroller. */
|
||||
function scrollerOf(from: HTMLElement): HTMLElement {
|
||||
@@ -46,38 +45,30 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
|
||||
const viewport = scrollport.getBoundingClientRect()
|
||||
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
||||
// Scroll events are hot: walk down one hit-test line and stop at the first
|
||||
// hit row with layout before considering the full mounted set. Starting at the
|
||||
// viewport edge preserves the reader's leading row when a later row is
|
||||
// inserted between already-visible messages. The fallback keeps jsdom and
|
||||
// pre-layout states deterministic; a virtualizer naturally bounds it.
|
||||
// The leading edge preserves nested call identity when it hits a row.
|
||||
// Chrome/gap misses use logarithmic layout reads over the ordered flex rows.
|
||||
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
|
||||
const content = list.getBoundingClientRect()
|
||||
const left = Math.max(viewport.left, content.left)
|
||||
const right = Math.min(viewport.right, content.right)
|
||||
const x = left + Math.max(0, right - left) / 2
|
||||
const height = visibleBottom - viewport.top
|
||||
let probes = 0
|
||||
for (
|
||||
let offset = 1;
|
||||
offset < height && probes < MAX_PAGING_ANCHOR_PROBES;
|
||||
offset = offset === 1 ? 16 : offset + 16
|
||||
) {
|
||||
probes++
|
||||
for (const element of document.elementsFromPoint(x, viewport.top + offset)) {
|
||||
const row = element instanceof HTMLElement
|
||||
? element.closest<HTMLElement>('[data-chat-anchor-key]')
|
||||
: null
|
||||
if (row !== null && list.contains(row)) return row
|
||||
}
|
||||
for (const element of document.elementsFromPoint(x, viewport.top + 1)) {
|
||||
const row = element instanceof HTMLElement
|
||||
? element.closest<HTMLElement>('[data-chat-anchor-key]')
|
||||
: null
|
||||
if (row !== null && list.contains(row)) return row
|
||||
}
|
||||
}
|
||||
const rows = [...list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
||||
const visibleRows = rows.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < visibleBottom
|
||||
})
|
||||
return visibleRows[0] ?? rows[0] ?? null
|
||||
const rows = list.querySelectorAll<HTMLElement>('[data-chat-flow] > [data-chat-flow-key]:not(:empty)')
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (rows.item(middle).getBoundingClientRect().bottom > viewport.top) high = middle
|
||||
else low = middle + 1
|
||||
}
|
||||
const row = rows[low]
|
||||
return row !== undefined && row.getBoundingClientRect().top < visibleBottom ? row : rows[0] ?? null
|
||||
}
|
||||
|
||||
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import { formatTokensPerSecond } from './message-chrome.ts'
|
||||
import { assistantStepReading } from '../contract/turn-metrics.ts'
|
||||
import { formatCacheHitPercent, formatTokens } from './token-format.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface WindowStats {
|
||||
@@ -77,19 +78,6 @@ export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats
|
||||
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
|
||||
* @param n - token count.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatTokens(n: number, t: ChatViewSlotProps['t']): string {
|
||||
const scaled = (v: number): string =>
|
||||
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
||||
if (n < 1_000) return String(n)
|
||||
if (n < 1_000_000) return t('number.thousand', { value: scaled(n / 1_000) })
|
||||
return t('number.million', { value: scaled(n / 1_000_000) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
||||
* @param ms - duration in milliseconds.
|
||||
@@ -105,26 +93,6 @@ export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
|
||||
})
|
||||
}
|
||||
|
||||
/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */
|
||||
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
|
||||
const denominatorQuotient = Math.floor(denominator / 200)
|
||||
const denominatorRemainder = denominator % 200
|
||||
let lower = 0
|
||||
let upper = 100
|
||||
while (lower < upper) {
|
||||
const candidate = Math.floor((lower + upper + 1) / 2)
|
||||
const factor = candidate * 2 - 1
|
||||
const threshold = factor * denominatorQuotient
|
||||
+ Math.ceil(factor * denominatorRemainder / 200)
|
||||
if (cacheReadTokens >= threshold) {
|
||||
lower = candidate
|
||||
} else {
|
||||
upper = candidate - 1
|
||||
}
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-ready cache-hit share of prompt-side input over the whole durable log.
|
||||
* @param usage - the session's token-usage projection value.
|
||||
@@ -134,35 +102,7 @@ function roundedIntegerPercent(cacheReadTokens: number, denominator: number): nu
|
||||
*/
|
||||
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
|
||||
const denominator = billedInputTokens(usage)
|
||||
if (denominator === 0) return null
|
||||
const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens
|
||||
if (missedInputTokens === 0) return '100'
|
||||
|
||||
const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator)
|
||||
if (integerPercent < 100) return String(integerPercent)
|
||||
|
||||
// At the first distinguishing precision, the rounded result is 100 minus
|
||||
// one to five units in the final decimal place. Scale only while the next
|
||||
// multiplication remains at or below the denominator, then derive that
|
||||
// final digit through exact small-factor comparisons.
|
||||
let decimalPlaces = 1
|
||||
let scaledDoubleGap = missedInputTokens * 200
|
||||
const denominatorTens = Math.floor(denominator / 10)
|
||||
while (scaledDoubleGap <= denominatorTens) {
|
||||
scaledDoubleGap *= 10
|
||||
decimalPlaces += 1
|
||||
}
|
||||
const denominatorOnes = denominator % 10
|
||||
let roundedLoss = 5
|
||||
for (let loss = 1; loss < 5; loss += 1) {
|
||||
const factor = loss * 2 + 1
|
||||
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
|
||||
if (scaledDoubleGap <= threshold) {
|
||||
roundedLoss = loss
|
||||
break
|
||||
}
|
||||
}
|
||||
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
|
||||
return formatCacheHitPercent(usage.cacheReadTokens, denominator)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo } from 'react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { TurnUsageDisclosure } from './TurnUsageDisclosure.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
@@ -35,19 +36,22 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
return (
|
||||
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
|
||||
{tail}
|
||||
<MessageIconActions
|
||||
text={assistantText(closing.blocks)}
|
||||
time={closing.time}
|
||||
runMs={runMs}
|
||||
ttftMs={data.ttftMs}
|
||||
tokensPerSecond={data.tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
extraActions={assistantActions}
|
||||
t={t}
|
||||
/>
|
||||
<div className={css.footer}>
|
||||
{data.tokenUsage === undefined ? null : <TurnUsageDisclosure usage={data.tokenUsage} t={t} />}
|
||||
<MessageIconActions
|
||||
text={assistantText(closing.blocks)}
|
||||
time={closing.time}
|
||||
runMs={runMs}
|
||||
ttftMs={data.ttftMs}
|
||||
tokensPerSecond={data.tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
extraActions={assistantActions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root[data-open] {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.root [data-disclosure-row]:focus-visible {
|
||||
border-radius: 6px;
|
||||
outline: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
|
||||
gap: 6px 16px;
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 22px);
|
||||
margin: 4px 0 0 22px;
|
||||
padding: 10px 16px 12px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.details dt,
|
||||
.details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.details .route {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.reasoning {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.totalLabel,
|
||||
.details .totalValue {
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--dsw-alias-separator-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.details {
|
||||
grid-template-columns: minmax(72px, auto) minmax(0, 1fr);
|
||||
gap-inline: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { DisclosureRow, IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
|
||||
import css from './TurnUsageDisclosure.module.css'
|
||||
|
||||
export interface TurnUsageDisclosureProps {
|
||||
usage: TurnTokenUsage
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
|
||||
return t('message.turnUsage.count', { count: formatTokens(value, t) })
|
||||
}
|
||||
|
||||
function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
|
||||
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
|
||||
}
|
||||
|
||||
/** Compact per-Turn usage summary with an opt-in bucket breakdown. */
|
||||
export function TurnUsageDisclosure({ usage, t }: TurnUsageDisclosureProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const cacheHit = usage.cacheReadTokens === undefined
|
||||
? null
|
||||
: formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
|
||||
const total = formatCompactCount(usage.totalTokens, t)
|
||||
const summary = cacheHit === null
|
||||
? total
|
||||
: t('message.turnUsage.summaryWithCache', { total, percent: cacheHit })
|
||||
const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
|
||||
|
||||
return (
|
||||
<DisclosureRow
|
||||
icon={<IconDataOutline16 />}
|
||||
title={t('message.turnUsage.title')}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
expandOnRowClick
|
||||
keepContentWhenOpen
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
className={css.root}
|
||||
chevronClassName={css.chevron}
|
||||
>
|
||||
<dl className={css.details} data-turn-usage-details>
|
||||
{routes !== '' && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.model')}</dt>
|
||||
<dd className={css.route}>{routes}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.input')}</dt>
|
||||
<dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
|
||||
{usage.cacheReadTokens !== undefined && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheRead')}</dt>
|
||||
<dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{usage.cacheWriteTokens !== undefined && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheWrite')}</dt>
|
||||
<dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.output')}</dt>
|
||||
<dd>
|
||||
{formatExactCount(usage.outputTokens, t)}
|
||||
{usage.reasoningTokens !== undefined && (
|
||||
<span className={css.reasoning}>
|
||||
{t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
|
||||
<dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
|
||||
</dl>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
|
||||
/**
|
||||
* Compact token count: 517 / 12.2K / 517K / 1.2M.
|
||||
* @param value - non-negative token count.
|
||||
* @param t - Chat locale seat.
|
||||
* @returns locale-owned compact display string.
|
||||
*/
|
||||
export function formatTokens(value: number, t: ChatViewSlotProps['t']): string {
|
||||
const scaled = (candidate: number): string =>
|
||||
candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10)
|
||||
if (value < 1_000) return String(value)
|
||||
if (value < 1_000_000) return t('number.thousand', { value: scaled(value / 1_000) })
|
||||
return t('number.million', { value: scaled(value / 1_000_000) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact integer token count with locale-owned digit grouping.
|
||||
* @param value - non-negative safe integer token count.
|
||||
* @param t - Chat locale seat.
|
||||
* @returns an unrounded display string.
|
||||
*/
|
||||
export function formatExactTokens(value: number, t: ChatViewSlotProps['t']): string {
|
||||
const digits = String(value)
|
||||
const groups: string[] = []
|
||||
for (let end = digits.length; end > 0; end -= 3) {
|
||||
groups.unshift(digits.slice(Math.max(0, end - 3), end))
|
||||
}
|
||||
return groups.join(t('number.groupSeparator'))
|
||||
}
|
||||
|
||||
/** Round a cache-read ratio to exact percentage units, with positive ties rounded up. */
|
||||
function roundedPercentUnits(cacheReadTokens: number, denominator: number, decimalPlaces: 0 | 1): number {
|
||||
const unitsPerPercent = decimalPlaces === 0 ? 1 : 10
|
||||
const scale = unitsPerPercent * 100
|
||||
const doubledScale = scale * 2
|
||||
const denominatorQuotient = Math.floor(denominator / doubledScale)
|
||||
const denominatorRemainder = denominator % doubledScale
|
||||
let lower = 0
|
||||
let upper = scale
|
||||
while (lower < upper) {
|
||||
const candidate = Math.floor((lower + upper + 1) / 2)
|
||||
const factor = candidate * 2 - 1
|
||||
const threshold = factor * denominatorQuotient
|
||||
+ Math.ceil(factor * denominatorRemainder / doubledScale)
|
||||
if (cacheReadTokens >= threshold) lower = candidate
|
||||
else upper = candidate - 1
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
function displayPercentUnits(units: number, decimalPlaces: 0 | 1): string {
|
||||
if (decimalPlaces === 0) return String(units)
|
||||
const whole = Math.floor(units / 10)
|
||||
const tenths = units % 10
|
||||
return tenths === 0 ? String(whole) : `${whole}.${tenths}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-ready cache-hit share without rounding a partial hit to 100%.
|
||||
* @param cacheReadTokens - exact prompt tokens served from cache.
|
||||
* @param promptTokens - exact aggregate prompt tokens.
|
||||
* @param decimalPlaces - ordinary-ratio precision; partial hits that would
|
||||
* round to 100 automatically use enough additional precision to stay honest.
|
||||
* @returns percentage text, or null when there was no prompt input.
|
||||
*/
|
||||
export function formatCacheHitPercent(
|
||||
cacheReadTokens: number,
|
||||
promptTokens: number,
|
||||
decimalPlaces: 0 | 1 = 0,
|
||||
): string | null {
|
||||
if (promptTokens === 0) return null
|
||||
const missedInputTokens = promptTokens - cacheReadTokens
|
||||
if (missedInputTokens === 0) return '100'
|
||||
|
||||
const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces)
|
||||
const fullHitUnits = decimalPlaces === 0 ? 100 : 1_000
|
||||
if (roundedUnits < fullHitUnits) return displayPercentUnits(roundedUnits, decimalPlaces)
|
||||
|
||||
let distinguishingPlaces = 1
|
||||
let scaledDoubleGap = missedInputTokens * 200
|
||||
const denominatorTens = Math.floor(promptTokens / 10)
|
||||
while (scaledDoubleGap <= denominatorTens) {
|
||||
scaledDoubleGap *= 10
|
||||
distinguishingPlaces += 1
|
||||
}
|
||||
const denominatorOnes = promptTokens % 10
|
||||
let roundedLoss = 5
|
||||
for (let loss = 1; loss < 5; loss += 1) {
|
||||
const factor = loss * 2 + 1
|
||||
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
|
||||
if (scaledDoubleGap <= threshold) {
|
||||
roundedLoss = loss
|
||||
break
|
||||
}
|
||||
}
|
||||
return `99.${'9'.repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`
|
||||
}
|
||||
@@ -59,6 +59,29 @@ export interface RetryChatData {
|
||||
readonly current: ModelRetryNode
|
||||
}
|
||||
|
||||
/** One provider/model route that contributed a billed request attempt. */
|
||||
export interface TurnTokenUsageRoute {
|
||||
readonly provider: string
|
||||
readonly model: string
|
||||
}
|
||||
|
||||
/** Exact provider-reported token accounting for every attempt in one completed Turn. */
|
||||
export interface TurnTokenUsage {
|
||||
/** Sum of uncached prompt input across all attempts. */
|
||||
readonly uncachedInputTokens: number
|
||||
readonly outputTokens: number
|
||||
/** Exact aggregate prompt plus output total across all attempts. */
|
||||
readonly totalTokens: number
|
||||
/** Present only when every attempt reported the bucket. */
|
||||
readonly cacheReadTokens?: number
|
||||
/** Present only when every attempt reported the bucket. */
|
||||
readonly cacheWriteTokens?: number
|
||||
/** Output subset, present only when every attempt reported it. */
|
||||
readonly reasoningTokens?: number
|
||||
/** Present only when every billed attempt has provider/model attribution. */
|
||||
readonly routes?: readonly TurnTokenUsageRoute[]
|
||||
}
|
||||
|
||||
/** Turn-local footer row that owns actions and optional feature contributions. */
|
||||
export interface TurnTailChatData {
|
||||
readonly turn: number
|
||||
@@ -70,6 +93,8 @@ export interface TurnTailChatData {
|
||||
readonly branchUnavailable: boolean
|
||||
readonly ttftMs?: number
|
||||
readonly tokensPerSecond?: number
|
||||
/** Exact per-Turn accounting; absent when the loaded evidence is incomplete. */
|
||||
readonly tokenUsage?: TurnTokenUsage
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import type {
|
||||
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
|
||||
} from '../contract/chat-nodes.ts'
|
||||
@@ -57,10 +58,13 @@ function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[
|
||||
} | undefined {
|
||||
if (event.type === 'assistant/message'
|
||||
|| event.type === 'assistant/chunk'
|
||||
|| event.type === 'step/start'
|
||||
|| event.type === 'step/end') {
|
||||
return { turn: event.data.turn, step: event.data.step }
|
||||
}
|
||||
if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
|
||||
if (event.type === 'llm/retry' || event.type === 'llm/retry-started') {
|
||||
return { turn: event.data.turn, step: event.data.step }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -138,6 +142,9 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
|
||||
}
|
||||
}
|
||||
const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn)
|
||||
const tokenUsage = context.start?.event.type === 'turn/start'
|
||||
? deriveTurnTokenUsage(context.matches.map(match => match.event))
|
||||
: undefined
|
||||
return {
|
||||
turn: end.event.data.turn,
|
||||
seq: end.event.seq,
|
||||
@@ -146,6 +153,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
|
||||
branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq,
|
||||
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
|
||||
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
|
||||
...tokenUsage === undefined ? {} : { tokenUsage },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const NS = 'chat'
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'view.chat': '对话',
|
||||
'number.groupSeparator': ',',
|
||||
'duration.compactSeconds': '{seconds}秒',
|
||||
'duration.compactMinutes': '{minutes}分{seconds}秒',
|
||||
'duration.milliseconds': '{milliseconds}毫秒',
|
||||
@@ -74,6 +75,16 @@ export const zh = {
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'message.ttft': '首 token {seconds}秒',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'message.turnUsage.title': '本轮用量',
|
||||
'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%',
|
||||
'message.turnUsage.model': '提供方 / 模型',
|
||||
'message.turnUsage.input': '未缓存输入',
|
||||
'message.turnUsage.cacheRead': '缓存读取',
|
||||
'message.turnUsage.cacheWrite': '缓存写入',
|
||||
'message.turnUsage.output': '输出',
|
||||
'message.turnUsage.reasoning': '(其中推理 {tokens})',
|
||||
'message.turnUsage.total': '总计',
|
||||
'message.turnUsage.count': '{count} tok',
|
||||
'duration.seconds': '{seconds}秒',
|
||||
'duration.minutes': '{minutes}分{seconds}秒',
|
||||
'command.running': '执行中…',
|
||||
@@ -93,6 +104,7 @@ export type ChatKey = keyof typeof zh
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'view.chat': 'Chat',
|
||||
'number.groupSeparator': ',',
|
||||
'duration.compactSeconds': '{seconds}s',
|
||||
'duration.compactMinutes': '{minutes}m{seconds}s',
|
||||
'duration.milliseconds': '{milliseconds}ms',
|
||||
@@ -161,6 +173,16 @@ export const en = {
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'message.ttft': 'TTFT {seconds}s',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
'message.turnUsage.title': 'Turn usage',
|
||||
'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%',
|
||||
'message.turnUsage.model': 'Provider / model',
|
||||
'message.turnUsage.input': 'Uncached input',
|
||||
'message.turnUsage.cacheRead': 'Cached input',
|
||||
'message.turnUsage.cacheWrite': 'Cache write',
|
||||
'message.turnUsage.output': 'Output',
|
||||
'message.turnUsage.reasoning': ' ({tokens} reasoning)',
|
||||
'message.turnUsage.total': 'Total',
|
||||
'message.turnUsage.count': '{count} tok',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'command.running': 'Running…',
|
||||
|
||||
@@ -9,7 +9,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { formatTokens } from '../src/client/chat/token-format.ts'
|
||||
import { en, zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ describe('ChatView', () => {
|
||||
|
||||
readerScroll(scroller, 100)
|
||||
|
||||
expect(hitTest).toHaveBeenCalledTimes(64)
|
||||
expect(hitTest).toHaveBeenCalledTimes(1)
|
||||
expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:1')
|
||||
} finally {
|
||||
if (originalHitTest !== undefined) {
|
||||
@@ -485,6 +485,60 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the first visible row when the viewport top hit-test misses', () => {
|
||||
const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
|
||||
const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`))
|
||||
const h = makeHarness(
|
||||
{ nodes },
|
||||
{ hasMore: true },
|
||||
)
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const rows = [...view.container.querySelectorAll<HTMLElement>('[data-chat-flow-key]')]
|
||||
let prepended = false
|
||||
let rowRectCalls = 0
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: 0, bottom: 200 } as DOMRect),
|
||||
)
|
||||
rows.forEach((row, index) => {
|
||||
vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => {
|
||||
rowRectCalls += 1
|
||||
const shift = prepended ? (index === 8 ? 400 : 500) : 0
|
||||
const top = 20 + (index - 8) * 60 + shift
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
})
|
||||
})
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
|
||||
const hitTest = vi.fn((_x: number, _y: number): Element[] => [])
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
configurable: true,
|
||||
value: hitTest,
|
||||
})
|
||||
try {
|
||||
rowRectCalls = 0
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(hitTest).toHaveBeenCalledTimes(1)
|
||||
expect(hitTest.mock.calls[0]?.[1]).toBe(1)
|
||||
expect(rowRectCalls).toBeLessThanOrEqual(6)
|
||||
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
prepended = true
|
||||
act(() => {
|
||||
h.setChat({ nodes: [assistant(2, 'older'), ...nodes] })
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(450) // reader offset 50 + first visible row's 400px shift
|
||||
} finally {
|
||||
if (originalHitTest !== undefined) {
|
||||
Object.defineProperty(document, 'elementsFromPoint', originalHitTest)
|
||||
} else {
|
||||
Reflect.deleteProperty(document, 'elementsFromPoint')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the fixture main line as independently keyed business nodes', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
|
||||
|
||||
@@ -500,6 +500,44 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(tail.branchUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes exact Turn usage only after pagination supplies the full lifecycle window', () => {
|
||||
const value = assembler([
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('usage-assistant', 'done'),
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 4,
|
||||
totalTokens: 17,
|
||||
cacheReadTokens: 2,
|
||||
cacheWriteTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'step/end', { turn: 1, step: 1 }),
|
||||
at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
], true)
|
||||
|
||||
expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toBeUndefined()
|
||||
|
||||
value.prepend([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toEqual({
|
||||
uncachedInputTokens: 10,
|
||||
outputTokens: 4,
|
||||
totalTokens: 17,
|
||||
cacheReadTokens: 2,
|
||||
cacheWriteTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
routes: [{ provider: 'fake', model: 'fake' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
|
||||
const value = assembler([
|
||||
at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
|
||||
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
|
||||
import { formatCacheHitPercent } from '../src/client/chat/token-format.ts'
|
||||
|
||||
interface StepSpec {
|
||||
seq: number
|
||||
@@ -139,6 +140,10 @@ describe('deriveTurnMetrics', () => {
|
||||
})
|
||||
|
||||
describe('footer figure formatters', () => {
|
||||
it('omits a redundant decimal zero in cache-hit percentages', () => {
|
||||
expect(formatCacheHitPercent(1, 2, 1)).toBe('50')
|
||||
})
|
||||
|
||||
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
|
||||
expect(formatLatencySeconds(840)).toBe('0.8')
|
||||
expect(formatLatencySeconds(1_000)).toBe('1')
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { TurnUsageDisclosure } from '../src/client/chat/TurnUsageDisclosure.tsx'
|
||||
import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
|
||||
import { en } from '../src/client/locale.ts'
|
||||
|
||||
const t = makeTranslate(en, commonEn)
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('TurnUsageDisclosure', () => {
|
||||
it('shows the exact compact summary and expands into provider facts', () => {
|
||||
const usage: TurnTokenUsage = {
|
||||
uncachedInputTokens: 5_060,
|
||||
cacheReadTokens: 4_940,
|
||||
cacheWriteTokens: 0,
|
||||
outputTokens: 5_800,
|
||||
reasoningTokens: 42,
|
||||
totalTokens: 15_800,
|
||||
routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
|
||||
}
|
||||
const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
|
||||
|
||||
expect(view.getByText('15.8K tok · Cache hit 49.4%')).toBeTruthy()
|
||||
expect(view.queryByRole('definition')).toBeNull()
|
||||
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
const details = view.container.querySelector('[data-turn-usage-details]') as HTMLElement
|
||||
expect(details).toBeTruthy()
|
||||
expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
|
||||
expect(details.textContent).toContain('Uncached input5,060 tok')
|
||||
expect(details.textContent).toContain('Cached input4,940 tok')
|
||||
expect(details.textContent).toContain('Cache write0 tok')
|
||||
expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)')
|
||||
expect(details.textContent).toContain('Total15,800 tok')
|
||||
})
|
||||
|
||||
it('omits unavailable optional facts instead of inventing values', () => {
|
||||
const usage: TurnTokenUsage = {
|
||||
uncachedInputTokens: 120,
|
||||
outputTokens: 30,
|
||||
totalTokens: 150,
|
||||
}
|
||||
const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
|
||||
|
||||
expect(view.getByText('150 tok')).toBeTruthy()
|
||||
expect(view.queryByText(/Cache hit/)).toBeNull()
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByText('Provider / model')).toBeNull()
|
||||
expect(view.queryByText('Cached input')).toBeNull()
|
||||
expect(view.queryByText('Cache write')).toBeNull()
|
||||
expect(view.queryByText(/reasoning/)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a partial cache hit below 100 and supports keyboard toggling', () => {
|
||||
const usage: TurnTokenUsage = {
|
||||
uncachedInputTokens: 1,
|
||||
cacheReadTokens: 999,
|
||||
outputTokens: 100,
|
||||
totalTokens: 1_100,
|
||||
}
|
||||
const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
|
||||
expect(view.getByText('1.1K tok · Cache hit 99.9%')).toBeTruthy()
|
||||
|
||||
const disclosure = view.getByRole('button')
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(disclosure, { key: 'Enter' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -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/experimental/webworker-runtime/README.md
|
||||
README.md: 0c445bcd4082a1ff10a6b91218086dfb9c95874c
|
||||
README.zh.md: 6acde51a0aae3483a6e0f1bc80c98d51e9fd47c7
|
||||
README.md: 65f61a4771e28b841cfd01f0cc082b0e54475cca
|
||||
README.zh.md: d2cc28bfe08a737ea871187e1a379e5a36acb7a9
|
||||
|
||||
@@ -24,7 +24,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
|
||||
- **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here.
|
||||
- **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here.
|
||||
- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop.
|
||||
- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation.
|
||||
- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。
|
||||
- **`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。
|
||||
- **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。
|
||||
- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。
|
||||
- **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。
|
||||
- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。
|
||||
|
||||
@@ -57,6 +57,8 @@ export const MODULE_PROXIES: Record<string, string> = {
|
||||
// the VFS, because a browser worker has no processes to fork.
|
||||
'node:child_process': './node/builtin_modules/implemented/child_process.ts',
|
||||
// Structural mocks: every symbol exists, every call throws.
|
||||
'node:dns/promises': './node/builtin_modules/mock/dns/promises.ts',
|
||||
'dns/promises': './node/builtin_modules/mock/dns/promises.ts',
|
||||
'node:net': './node/builtin_modules/mock/net.ts',
|
||||
'node:stream': './node/builtin_modules/implemented/stream.ts',
|
||||
'node:vm': './node/builtin_modules/mock/vm.ts',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* `node:dns/promises` stub. The static WebWorker preview has no DNS resolver;
|
||||
* reaching public-address preflight must fail loud instead of inventing an
|
||||
* address or bypassing the native HTTP provider's SSRF policy.
|
||||
*/
|
||||
import { notImplementedFail } from '../../../notImplementedFail.ts'
|
||||
|
||||
const MODULE = 'node:dns/promises'
|
||||
|
||||
/** DNS lookup (unavailable in the worker host). */
|
||||
export const lookup: typeof import('node:dns/promises').lookup = notImplementedFail(MODULE, 'lookup')
|
||||
|
||||
/** CommonJS interop marker: the worker loader hands `default` to default imports. */
|
||||
export const __esModule = true
|
||||
|
||||
/** The `node:dns/promises` declarations this module stands in for. */
|
||||
type NodeFace = Partial<typeof import('node:dns/promises')>
|
||||
|
||||
/** CommonJS default export: the members `require()` hands a caller of this module. */
|
||||
export default { lookup } satisfies NodeFace
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user