Merge remote-tracking branch 'origin/master' into xtr/session-store-write-lifecycle

# Conflicts:
#	apps/web/tests/new-session-refused.e2e.ts
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
_Kerman committed 2026-10-20 15:31:09 +08:00
commit e7ffab7902
339 files changed
+4943 -1504

No files matched your search

@@ -24,8 +24,8 @@
en: ec3703b7e7c6b23d
zh: 325755e6e90092da
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
en: f734fdc57a6546c9
zh: 027c28cbc584a8c2
en: 6dba3b9a51c28b6d
zh: 58a93f6ba2d269ea
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/tool-consumer-behavior:
en: 62127883453dd79c
zh: 01503a8177a0ddab
@@ -55,19 +55,16 @@
zh: 64032ea6ab61b836
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/mirror-claude-code-s-url-prompt-webfetch-shape:
en: 56a080de39e96056
zh: ae4d070aa7657503
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/validate-dns-and-then-call-an-ordinary-fetch:
en: aa6c1e7e6374a875
zh: 045c8af6cd44c3e1
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/block-private-looking-hostname-strings-without-pinning-resolved-addresses:
en: 3d906f3056733479
zh: d652723eb5372007
zh: 937336a73de70518
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/restrict-fetch-to-public-addresses:
en: af3014c0de7ff66f
zh: 509040c9183327fe
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/require-per-call-approval-before-public-fetches:
en: f8d1fc8e9c9659cf
zh: da5792520e35cba0
en: 3ea94ffb142d144a
zh: 89645f68412f6769
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
en: 12fd20ba54818215
zh: c1ca000b13666701
en: 9774448fd6b21c41
zh: 8411c590ad70d5c4
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
en: 48b7b8e7310cd56b
zh: d406cd6a8a649e58
@@ -202,7 +202,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, 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 `web_fetch` implementation is an anonymous HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, 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, or internal auth tokens. It does not restrict destination addresses.
The seam request stays smaller than OpenCode's model-facing tool:
@@ -238,14 +238,12 @@ 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; 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.)
- Only same-origin redirects are followed automatically; each followed hop repeats URL validation. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call. (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.
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.
Requests use the process-wide dispatcher, so the installed outbound proxy policy and the system resolver decide the connection. Loopback, private-network, and cloud metadata destinations are reachable.
## Tool consumer behavior
@@ -313,17 +311,13 @@ 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
### Restrict fetch to public addresses
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.
Rejected. Resolving each hostname, rejecting any non-public answer, and pinning the connection to the validated set does not confine the model, which reaches the same networks through mounted shell tools, and it refuses every page on hosts whose DNS answers with non-public addresses, such as TUN-mode proxies with fake-IP DNS. Deployments that must keep the model away from private destinations need network confinement, a `tools/pre-execute` policy, or no fetch tool.
### 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`.
Rejected for the shipped presets. Per-call confirmation would interrupt ordinary browsing without controlling data egress reliably: a model can reach the same networks through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
## Consequences
@@ -335,7 +329,7 @@ Rejected for the shipped presets. Public-address validation blocks SSRF destinat
**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.** 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.
**Fetch is a network boundary, not just a read-only tool.** `web_fetch` reaches any destination the host can, including loopback and private-network services, a model can disclose data through a 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.
@@ -202,7 +202,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`
## Fetch 请求与结果 schema
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。
`web_fetch` 的实现是一个匿名 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证或内部认证令牌。它不限制目的地址。
seam 请求比 OpenCode 的面向模型工具更小:
@@ -238,14 +238,12 @@ 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 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 仅自动跟随同源重定向;每个跟随的跳转都会重复 URL 校验。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。
只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
请求使用进程级 dispatcher,因此由已安装的出站代理策略与系统解析器决定连接。loopback、内网与云 metadata 目的地址都可访问。
## 工具消费方行为
@@ -313,17 +311,14 @@ fetch 提供方的资源控制:
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。
### 验证 DNS 后调用普通 fetch
<a id="restrict-fetch-to-public-addresses"></a>
### 只允许抓取公开地址
否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。
### 只阻断看起来像私网的 hostname 字符串,不固定解析地址
否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。
否决。解析每个 hostname、拒绝任何非公开解析结果并把连接固定到已验证地址,并不能约束模型,因为模型可以通过已挂载的 shell 工具访问相同网络;同时在 DNS 返回非公开地址的主机上(例如使用 fake-IP DNS 的 TUN 模式代理),这会拒绝所有页面。需要阻止模型访问私有目的地址的部署应使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
### 在公开抓取前要求逐次审批
已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
已交付的 preset 不采用这一方案。逐次确认会打断普通浏览,却不能可靠控制数据出站:模型可以通过已挂载的 shell 工具访问相同网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
## 后果
@@ -335,7 +330,7 @@ fetch 提供方的资源控制:
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 可以访问主机能访问的任何目的地址,包括 loopback 与内网服务;模型可以通过 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
@@ -9,14 +9,14 @@
en: 6379f0a2522f4c46
zh: c45ffeb5089be274
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision:
en: 92526818ff4870c2
zh: 8c54ff3f4941d5ad
en: 6435c336ab53ca0f
zh: 57070799a7061f85
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered:
en: df6c078f239a11f5
zh: 914da26836aa373b
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences:
en: 6a4acbab915f1bd1
zh: 2c8ffd62ba74ff96
en: ff1898a64e5db79c
zh: 0a77d02385fb3506
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/testing:
en: 0d7eaa666c94a5cd
zh: 210fa098f154d137
en: fddb0be6abbd809e
zh: 88843f0f3ea60555
@@ -36,7 +36,7 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
**Rejection is quiet, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead.
**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently.
**`web_fetch` uses the installed dispatcher.** The provider sends every request through the process-wide dispatcher, so a proxied hop goes through the proxy, which performs the origin's DNS, and a hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, connects directly. The provider does not restrict destination addresses ([web capability seam](2026-06-24-web-capability-seam.md#restrict-fetch-to-public-addresses)).
The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop.
@@ -54,7 +54,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
**Every call site carries an egress test.** Each owning package's `egress.spec.ts` drives its actual transport through a fake proxy and checks the observed route. These tests cover search backends, pi-ai discovery, MCP over HTTP, child Node processes, worker threads and telemetry's direct-route exception. They detect dependency transport changes that a static call-site check cannot observe.
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and a call site that genuinely owns its transport says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
## Alternatives considered
@@ -78,8 +78,6 @@ A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied
Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness.
`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters.
Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field.
The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first.
@@ -88,7 +86,7 @@ The suite is hermetic against the developer's own environment: every Vitest conf
`packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce.
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path.
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts that a proxied hop reaches the proxy, a bypassed hop connects directly, and the cross-origin redirect refusal survives on the proxied path.
`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree.
@@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。
**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。
**`web_fetch` 使用已安装的 dispatcher。** 该提供方的每个请求都经过进程级 dispatcher,因此代理转发的一跳经过代理,由代理执行 origin 的 DNS;被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,则直接连接。该提供方不限制目的地址([web capability seam](2026-06-24-web-capability-seam.zh.md#restrict-fetch-to-public-addresses))。
URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
@@ -54,7 +54,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
**每个出网点都配有出网测试。** 各所属包中的 `egress.spec.ts` 通过假代理驱动实际传输,并检查观察到的路由。这些测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、子 Node 进程、worker 线程和遥测的直连例外。它们可发现静态调用点检查无法观察到的依赖传输变化。
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;确实自有传输的调用点用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
## Alternatives considered
@@ -78,8 +78,6 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。
`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。
userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。
测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。
@@ -88,7 +86,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言代理转发的一跳抵达代理、被绕过的一跳直接连接,且跨域重定向拒绝在代理路径上依然成立。
`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
en: 9105f8148e107f43
zh: a160e87bb0605da4
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: 1b61d351c4d2bdf1
zh: d6f8180b30d97b34
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
en: 9d8486586d044709
zh: 24c7249421fd2541
@@ -0,0 +1,51 @@
# Agent Note: Delivering messages from outside an Agent through the Agent controller
Status: implemented
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
## Problem
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
## Decision
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
| Part | Contract |
|---|---|
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
## Alternatives considered
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
## Consequences
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
@@ -0,0 +1,51 @@
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
Status: implemented
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
## 问题
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
## 决策
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
| 部分 | 约定 |
|---|---|
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
## 备选方案
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
## 影响
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
@@ -15,11 +15,11 @@
en: ec653b59d62ed95f
zh: 0d675616059930ba
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
en: a95618f172ef63ee
zh: 306556be09c56bdf
en: ad7b3e4fbe528cf2
zh: 8ab8779bfdaaa4fa
/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership:
en: 8051417b08739ed0
zh: c11d35a346014436
en: 7566e400f9ab3a51
zh: 118b2e84d0030480
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
en: d5a8e36b545f1c04
zh: d741fd0789401c62
@@ -33,5 +33,5 @@
en: a55da02f4d3c06c0
zh: 3c1a624f03f34f28
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
en: 41f9a331c7aca397
zh: 17a0fd53e133c2f5
en: 4c84e7bd035b52a7
zh: 4e5687bda0a47706
@@ -24,7 +24,7 @@ The guide specifies API-key Bearer authentication, and requests send only the do
Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful.
The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
The provider preserves service order and maps URL, title, `snippet`, `summary`, and nullable `date` into shared sources. Following the guide, the tool gives the model each source's `summary` and keeps the short `snippet` for the search card and its replay metadata. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. `dsh-tool-web` therefore collapses summary whitespace and caps each summary at `searchSummaryMaxChars`, 800 characters by default, the upper end of the guide's typical range; one call's summaries stay within the source cap times that cap. The summary rides in the tool output value and rendered text, which the Session already records. The provider does not consume full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data.
@@ -32,7 +32,7 @@ Direct retrieval emits no auxiliary model-request event because Harness makes no
Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged.
The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
The HTTP provider retains its own URL validation and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
### Default migration
@@ -78,7 +78,6 @@ Provider tests cover request mapping, empty results, malformed responses, indepe
The following capabilities remain outside the implemented integration:
- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16.
- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language.
- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources.
- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes.
@@ -24,7 +24,7 @@ Status: implemented
直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。
提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
提供方保留服务端顺序,将 URL、标题、`snippet`、`summary` 和可为 null 的 `date` 映射为共享来源。按照指南,工具把每个来源的 `summary` 提供给模型,短的 `snippet` 留给搜索卡片及其回放元数据。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。因此 `dsh-tool-web` 合并摘要中的空白,并将每条摘要限制在 `searchSummaryMaxChars` 以内,默认 800 个字符,即指南典型范围的上限;一次调用的摘要总长不超过来源上限乘以该上限。摘要随工具输出值和渲染文本一起进入 Session 记录。提供方不使用完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。
@@ -32,7 +32,7 @@ Status: implemented
Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。
HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
### 默认值迁移
@@ -78,7 +78,6 @@ Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一
以下能力尚未包含在已实现的接入中:
- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。
- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。
- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。
- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。
+3
View File
@@ -40,6 +40,8 @@ flowchart LR
cfg --> plugin_dsh_base_user_questions
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
cfg --> plugin_dsh_base_agent
plugin_dsh_base_agent_controller["agent-controller<br/>@deepseek-ai/dsh-agent-controller"]
cfg --> plugin_dsh_base_agent_controller
plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
@@ -218,6 +220,7 @@ flowchart LR
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
| `agent` | `@deepseek-ai/dsh-agent` |
| `agent-controller` | `@deepseek-ai/dsh-agent-controller` |
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
+2 -2
View File
@@ -24,8 +24,8 @@
en: 850434e8ca162221
zh: 4893613f97a21eb8
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
en: f40edcf903a51f3f
zh: 8a9a915451047c8b
/dsh-cli-behavior-reference/source-execution:
en: 05c2b23790f7b296
zh: 51f809020fb230a1
+1 -1
View File
@@ -134,7 +134,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 `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. 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`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. 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`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
+1 -1
View File
@@ -136,7 +136,7 @@ dsh web --help
## 共享部署行为
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
+2 -2
View File
@@ -195,8 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
// The bundle's detail document is shown between its components and its source.
await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+2 -2
View File
@@ -212,8 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
// The bundle's detail document is shown between its components and its source.
await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+5
View File
@@ -45,7 +45,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
const before = await readFile(settingsPath, 'utf8')
const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const plugins = page.locator('[data-plugin-panel]')
await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
await plugins.getByRole('button', { name: '更多', exact: true }).click()
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await card.textContent()).toContain('请切换到 PTC 模式')
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- paragraph:
- img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
- paragraph:
- text: 展开
- strong: 思考
- text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
- heading "使用方法" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- listitem:
- text: 展开
- strong: 思考
- text: 行。点击
- strong: 查看原文
- text: 显示原文,点击
- strong: 查看译文
- text: 切换回译文。
- listitem:
- text: 在此插件的页面中选择
- strong: 翻译服务
- text: 和
- strong: 目标语言
- text: ,然后点击
- strong: 保存
- text: 。
- code: auto
- text: 表示跟随界面语言;也可以填写
- code: zh
- text: 、
- code: en
- text: 、
- code: ja
- text: 等语言代码。
- listitem:
- text: 翻译失败时,原文保持显示,并提供
- strong: 重试
- text: 。
- heading "注意事项" [level=2]
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- listitem: 你展开的思考内容可能包含你的代码和对话细节。
- listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
- listitem:
- text: 如果已配置 DeepSeek 账号或官方 API,还会出现
- strong: DeepSeek Flash · 账号(付费)
- text: 和
- strong: DeepSeek Flash · 官方 API(付费)
- text: 。此时每段没有已保存译文的内容都是一次付费请求。
- listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
- listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
- listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- paragraph:
- img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
- paragraph:
- text: 展开
- strong: 思考
- text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
- heading "使用方法" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- listitem:
- text: 展开
- strong: 思考
- text: 行。点击
- strong: 查看原文
- text: 显示原文,点击
- strong: 查看译文
- text: 切换回译文。
- listitem:
- text: 在此插件的页面中选择
- strong: 翻译服务
- text: 和
- strong: 目标语言
- text: ,然后点击
- strong: 保存
- text: 。
- code: auto
- text: 表示跟随界面语言;也可以填写
- code: zh
- text: 、
- code: en
- text: 、
- code: ja
- text: 等语言代码。
- listitem:
- text: 翻译失败时,原文保持显示,并提供
- strong: 重试
- text: 。
- heading "注意事项" [level=2]
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- listitem: 你展开的思考内容可能包含你的代码和对话细节。
- listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
- listitem:
- text: 如果已配置 DeepSeek 账号或官方 API,还会出现
- strong: DeepSeek Flash · 账号(付费)
- text: 和
- strong: DeepSeek Flash · 官方 API(付费)
- text: 。此时每段没有已保存译文的内容都是一次付费请求。
- listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
- listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
- listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
@@ -1,7 +1,7 @@
- dialog "选择要添加的模型":
- heading "选择要添加的模型" [level=2]
- dialog "选择模型":
- heading "选择模型" [level=2]
- button "关闭"
- paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。
- paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。
- searchbox "搜索模型"
- button "取消全选"
- list:
@@ -15,4 +15,4 @@
- checkbox "MiniMax-M3" [checked]
- text: MiniMax-M3
- button "取消"
- button "添加所选"
- button "更新列表"
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
- region "Details":
- heading "Fixture bundle" [level=1]
- paragraph: English | 中文
- paragraph:
- text: Registry fixture for the Web plugin-manager tests. It declares no
- code: meta.detail
- text: ", so its page shows this README."
- heading "Components" [level=4]
- text: 3 total · 3 off
- list:
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
- region "详细介绍":
- heading "Fixture bundle" [level=1]
- paragraph: English | 中文
- paragraph:
- text: Registry fixture for the Web plugin-manager tests. It declares no
- code: meta.detail
- text: ", so its page shows this README."
- heading "包含的组件" [level=4]
- text: 共 3 个 · 3 已停用
- list:
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -6,7 +6,7 @@ Focus at 500ms:
- tooltip "刷新"
failure: pending
- button "刷新" [disabled]
{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
failure: settled
- button "刷新"
{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
@@ -0,0 +1,35 @@
- table:
- rowgroup:
- row "SYSTEM, Initial System Prompt":
- cell "SYSTEM"
- cell "Initial System Prompt"
- row "USER, Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop.":
- cell "Turn 1 USER": USER
- cell "Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."
- 'row "CONTEXT, Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some pl…"':
- cell "CONTEXT"
- 'cell "Current runtime context. This snapshot supersedes earlier runtime-context snapshots. Current working directory: \"{{cwd}}/workspace\". Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some pl…"'
- 'row "CONTEXT, Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."':
- cell "CONTEXT"
- 'cell "Time sampled while preparing turn 1, step 1: 2026-01-15T{{clock}}+{{clock}}[Asia/Shanghai] Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone. Elapsed since the preceding model-visible message: unavailable."'
- row "Request 1, compaction":
- 'cell "Request #1"':
- 'button "Request #1"'
- cell
- 'row "USER, Interjection: include the word BANANA in your final reply."':
- cell "Turn 2 USER": USER
- 'cell "Interjection: include the word BANANA in your final reply."'
- row "USER, Also acknowledge this second message.":
- cell "USER"
- cell "Also acknowledge this second message."
- row "SYSTEM, Tools Updated":
- cell "SYSTEM"
- cell "Tools Updated"
- 'row "CONTEXT, Tool added: trajectory_order_probe"':
- cell "CONTEXT"
- 'cell "Tool added: trajectory_order_probe"'
- row "Request 2, ASSISTANT, Great, let's move forward. BANANA!":
- 'cell "Request #2 ASSISTANT"':
- 'button "Request #2"'
- text: ASSISTANT
- cell "Great, let's move forward. BANANA!"
@@ -0,0 +1,7 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/web/tests/fixtures/plugins/fixture-bundle/README.md
/fixture-bundle:
en: 987d3ae4a4d79382
zh: 4b7ce36da8833efe
@@ -0,0 +1,5 @@
# Fixture bundle
English | [中文](README.zh.md)
Registry fixture for the Web plugin-manager tests. It declares no `meta.detail`, so its page shows this README.
@@ -0,0 +1,5 @@
# 测试组合包
[English](README.md) | 中文
Web 插件管理测试用的 registry 测试组合包。它没有声明 `meta.detail`,所以它的页面显示这份 README。
+52
View File
@@ -61,6 +61,35 @@ function parentPathFixture(path: string): string {
].join('\n')
}
/** Build a settled turn whose relative link follows a directory change into a nested checkout. */
function changedDirectoryFixture(path: string): string {
const session = Session.create(SessionId('changed-directory-links-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Enter the worktree and link its notes.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', { title: 'Changed directory links', messageSeqs: [user.seq], source: { kind: 'fallback' } })
session.append('working-directory/change', { cwd: '{{cwd}}/.agents/worktrees/w' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1, step: 1, stream: [],
message: createAssistantMessage({
content: [{ type: 'text', text: `[Worktree notes](${path})` }],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify(event)),
'',
].join('\n')
}
describe('web e2e: Markdown file links', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -177,4 +206,27 @@ describe('web e2e: Markdown file links', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('resolves relative links from the Session\'s current working directory', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-directory-file-links'))
const worktree = join(scaffold.workspaceCwd, '.agents/worktrees/w')
await mkdir(worktree, { recursive: true })
await writeFile(join(scaffold.workspaceCwd, 'notes.txt'), 'original checkout\n')
await writeFile(join(worktree, 'notes.txt'), 'worktree checkout\n')
const id = await seedSession(scaffold, changedDirectoryFixture('notes.txt'), 'changed-directory-file-links')
await page.reload({ waitUntil: 'load' })
const workspace = page.getByRole('treeitem').first()
await workspace.waitFor()
if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
await page.locator(`[data-row-key="session:${id}"]`).click()
await page.getByRole('button', { name: 'Worktree notes', exact: true }).click()
const column = page.locator('[data-rightbar-col]')
await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(worktree, 'notes.txt'))
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('worktree checkout\n')
const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
await mkdir(artifacts, { recursive: true })
await page.screenshot({ path: join(artifacts, 'changed-directory-file-links.png'), animations: 'disabled' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
+1 -1
View File
@@ -275,7 +275,7 @@ describe('web e2e: Markdown image rendering', () => {
await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor()
expect(mediaResponses).toEqual(new Map([
[join(scaffold.workspaceCwd, 'valid.png'), 200],
[`${scaffold.workspaceCwd}/./local-image.png`, 200],
[join(scaffold.workspaceCwd, 'local-image.png'), 200],
[join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200],
[join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), 200],
[join(scaffold.workspaceCwd, 'oversized.png'), 413],
+8 -7
View File
@@ -241,14 +241,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('filters the discovered model catalog and clears hidden selections', async () => {
it('filters the discovered model catalog and deselects only visible candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await settingsDialog.getByText('自定义设置').click()
await settingsDialog.getByRole('button', { name: '获取可用模型' }).click()
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.waitFor({ timeout: 10_000 })
const boxes = picker.getByRole('checkbox')
const count = await boxes.count()
@@ -268,12 +268,13 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await search.fill('')
await expect.poll(async () => boxes.count()).toBe(count)
const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))
expect(restored).toEqual(Array.from({ length: count }, () => false))
expect(restored.filter(checked => !checked)).toHaveLength(1)
expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false)
await picker.getByRole('button', { name: '全选' }).waitFor()
await picker.getByRole('button', { name: '全选' }).click()
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="选择要添加的模型"]',
'[role="dialog"][aria-label="选择模型"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE)
@@ -517,11 +518,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('button', { name: '删除模型 1' }).click()
await dialog.getByRole('button', { name: '获取可用模型' }).click()
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
const picker = page.getByRole('dialog', { name: '选择模型' })
await picker.getByRole('button', { name: '取消全选' }).click()
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
await picker.getByRole('button', { name: '添加所选' }).click()
await picker.getByRole('button', { name: '更新列表' }).click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await image.isChecked()).toBe(true)
await dialog.getByRole('button', { name: '保存', exact: true }).click()
@@ -530,7 +531,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
} finally {
await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
await edit.waitFor({ state: 'detached' })
await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' })
await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+14 -2
View File
@@ -27,6 +27,7 @@ const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const ID_SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-id-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
@@ -202,9 +203,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
{ timeout: 10_000 },
).toBe(0)
await search.fill('WATERFALL')
const resultTree = page.getByRole('tree', { name: 'Search results' })
const result = resultTree.getByRole('treeitem')
// The seed log carries its id only in the header, so this mid-id,
// differently cased fragment can match only through local id matching.
await search.fill('PANES-WEB')
await expect.poll(() => result.count(), { timeout: 10_000 }).toBe(1)
await page.getByRole('status', { name: 'Searching session history…' }).waitFor({ state: 'detached', timeout: 30_000 })
await compareOrRefreshGolden(
ID_SEARCH_EXPECTED,
await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd),
MODE,
)
await search.fill('WATERFALL')
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
timeout: 10_000,
@@ -551,7 +563,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
'terminal-card.expected.md', 'timing.expected.md',
])
})
+29 -5
View File
@@ -584,8 +584,27 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
expect(await panel.getByText('实验性', { exact: true }).count())
.toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length)
const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
expect(await Promise.all(cards.map(async card => [
await card.getAttribute('data-plugin-package'),
await card.getByText('实验性', { exact: true }).count(),
]))).toEqual([
['@deepseek-ai/dsh-experimental-agent-team-profile', 1],
['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
['@deepseek-ai/dsh-experimental-auto-review', 1],
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
['@deepseek-ai/dsh-cua-bundle', 1],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
['@deepseek-ai/dsh-experimental-session-search', 1],
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-subagent-codex', 0],
['@deepseek-ai/dsh-subagent-claude-code', 0],
])
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
@@ -727,11 +746,12 @@ describe('web e2e: plugin manager', () => {
}
expect(await preview.getAttribute('src')).toBe(chineseSource)
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
// Detail icons have alt=""; this bundle's README detail document has no images.
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
// A bundle without meta.detail still shows its README; this one has no images.
await panel.locator('[data-detail-document] h1').waitFor()
expect(await panel.getByRole('img').count()).toBe(0)
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
expect(tripwire.pageErrors).toEqual([])
})
@@ -747,6 +767,9 @@ describe('web e2e: plugin manager', () => {
expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
// The README detail document loads asynchronously after the description.
const readmeHeading = panel.locator('[data-detail-document]').getByRole('heading', { name: 'Fixture bundle', exact: true })
await readmeHeading.waitFor()
expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
@@ -757,6 +780,7 @@ describe('web e2e: plugin manager', () => {
expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0)
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
await readmeHeading.waitFor()
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
}), MODE)
+2 -2
View File
@@ -468,8 +468,8 @@ function assertLeanChildRecord(agent: Agent): void {
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
* sandbox row confines, `mcp_*` servers spawn outside `ctx.shell`, and `ralph`
* runs unsupervised rounds whose completion is a worker self-report.
* `web_fetch` is present because public-address enforcement and one-shot
* approval now confine its model-selected request target. The composition
* `web_fetch` is present because it adds no network reach beyond the
* mounted shell tools. The composition
* Agent Note owns the rationale and its sources.
*/
const EXPECTED_TOOLS = [
+135 -3
View File
@@ -28,6 +28,8 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
const RETAINED_TRAJECTORY_EXPECTED = fileURLToPath(new URL('./expected/retained-steering/trajectory.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The question composer replaces the textarea, so fill → Queue row → Steer
// starts only after request/context and must finish before the first replay
@@ -80,9 +82,11 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
// clocks must read the fixture day or a run across Asia/Shanghai midnight
// renders the same message with a date prefix.
hostClock = pinHostClock()
scaffold = await launchWebScaffold(MODE === 'record'
? {}
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true })
// Trajectory previews truncate before path normalization; pin the input length across hosts.
scaffold = await launchWebScaffold({
workspacePathLength: 112,
...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true }),
})
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -193,6 +197,13 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.locator('[data-trajectory-row-key]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
await compareOrRefreshGolden(
TRAJECTORY_EXPECTED,
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
MODE,
)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 200_000)
@@ -200,10 +211,131 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
'trajectory.expected.md',
])
})
})
describe.skipIf(MODE === 'record')('web e2e: retained steering and a post-stop message', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let hostClock: MockInstance<typeof Date.now> | undefined
const sessionEvents: SessionEvent[] = []
const requestInputs: string[][] = []
const releaseQuestion = Promise.withResolvers<undefined>()
const FOLLOWUP = 'Also acknowledge this second message.'
const RESPONSE = "Great, let's move forward. BANANA!"
beforeAll(async () => {
hostClock = pinHostClock()
scaffold = await launchWebScaffold({
replayFixture: FIXTURE, compareReplaySession: false, workspacePathLength: 112,
})
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
scaffold.ctx.on('llm/stream', async function* (options, next) {
requestInputs.push(options.messages.flatMap(message =>
message.role === 'user'
? message.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: []))
for await (const chunk of next()) {
yield chunk
if (requestInputs.length === 1 && chunk.type === 'block-start') await releaseQuestion.promise
}
}, { prepend: true })
browser = await chromium.launch()
page = await newEnglishPage(browser)
await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
})
afterAll(async () => {
releaseQuestion.resolve(undefined)
try {
try {
await browser?.close()
} finally {
await scaffold?.close()
}
} finally {
hostClock?.mockRestore()
}
})
it('keeps both inputs before the response in Chat and Trajectory, including after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-trajectory-input-order'))
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.fill(PROMPT)
await input.press('Enter')
await expect.poll(() => requestInputs.length, { timeout: 10_000 }).toBe(1)
await input.fill(STEER)
await input.press('Meta+Enter')
await page.locator('[data-pending-steering]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 })
await page.getByRole('button', { name: 'Stop generating', exact: true }).click()
releaseQuestion.resolve(undefined)
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(1)
expect(claimedMessages(sessionEvents, STEER)).toHaveLength(0)
scaffold.ctx.effect(() => scaffold.ctx.tools.register({
name: 'trajectory_order_probe',
description: 'A tool added between requests to exercise the tool catalog update.',
parameters: { type: 'object', properties: {} },
output: { schema: { type: 'null' }, render: () => [] },
async execute() { return null },
}))
await input.fill(FOLLOWUP)
await input.press('Enter')
await expect.poll(() => sessionEvents.filter(event => event.type === 'turn/end').length, { timeout: 10_000 }).toBe(2)
const first = claimedMessages(sessionEvents, STEER)
const second = claimedMessages(sessionEvents, FOLLOWUP)
const response = sessionEvents.findLast(event => event.type === 'assistant/message')
expect(first).toHaveLength(1)
expect(second).toHaveLength(1)
const loggedInputs = [
{ text: STEER, seq: first[0]!.seq },
{ text: FOLLOWUP, seq: second[0]!.seq },
].sort((left, right) => left.seq - right.seq)
expect(requestInputs.at(-1)?.filter(text => text === STEER || text === FOLLOWUP))
.toEqual(loggedInputs.map(value => value.text))
expect(loggedInputs.every(value => value.seq < response!.seq)).toBe(true)
const expectOrder = async (selector: string, includeTools = false) => {
await expect.poll(async () => {
const text = await page.locator(selector).textContent()
const expected = [
...loggedInputs.map(value => value.text),
...(includeTools ? ['Tools Updated'] : []),
RESPONSE,
]
const positions = expected.map(value => text?.indexOf(value) ?? -1)
return positions.every(position => position >= 0)
&& positions.every((position, index) => index === 0 || positions[index - 1]! < position)
&& expected.every(value => text?.indexOf(value) === text?.lastIndexOf(value))
}, { timeout: 10_000 }).toBe(true)
}
await page.getByText(RESPONSE, { exact: true }).waitFor()
await expectOrder('[class*="centerCol"]')
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await expectOrder('[data-trajectory-scroll]', true)
await compareOrRefreshGolden(
RETAINED_TRAJECTORY_EXPECTED,
await captureStableAria(page, '[data-trajectory-scroll]', scaffold.workspaceCwd),
MODE,
)
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await expectOrder('[data-trajectory-scroll]', true)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => {
let scaffold: WebScaffold
let browser: Browser
+2 -2
View File
@@ -208,7 +208,7 @@ export function probeFreePort(): Promise<number> {
*/
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('textbox', { name: 'Choose workspace' }).click()
await page.getByRole('button', { name: 'Add workspace', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Edit path' }).click()
@@ -234,7 +234,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
*/
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise<void> {
mkdirSync(join(root, name), { recursive: true })
await page.getByRole('textbox', { name: '选择工作区' }).click()
await page.getByRole('button', { name: '添加工作区', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑路径' }).click()
+21 -3
View File
@@ -50,6 +50,16 @@ function resultSnippet(queryIndex: number, ordinal: number): string {
return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
}
/** One provider result's multi-line model-facing summary, by 1-based provider order. */
function resultSummary(queryIndex: number, ordinal: number): string {
return `Snapshot search ${queryIndex + 1} summary ${ordinal}:\n\nthe model reads this longer excerpt\nfrom the Search API.`
}
/** The rendered summary: whitespace collapsed to single spaces. */
function renderedSummary(queryIndex: number, ordinal: number): string {
return resultSummary(queryIndex, ordinal).replace(/\s+/gu, ' ')
}
/** One provider result's publication date, by 1-based provider order (July 2026 days 01..12). */
function resultPageAge(ordinal: number): string {
return `2026-07-${String(ordinal).padStart(2, '0')}`
@@ -66,6 +76,11 @@ const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, que
publishedAt: resultPageAge(ordinal),
}))).slice(0, WEB_SEARCH_MAX_RESULTS)
/** Model-visible source lines for the kept sources: the summary replaces the display snippet. */
const KEPT_SOURCE_LINES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) =>
`- [${resultTitle(queryIndex, ordinal)}](${resultUrl(queryIndex, ordinal)}) — ${renderedSummary(queryIndex, ordinal)} (${resultPageAge(ordinal)})`,
)).slice(0, WEB_SEARCH_MAX_RESULTS)
/** URLs omitted after the combined source cap is reached. */
const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
(_query, queryIndex) => resultUrl(queryIndex, ordinal),
@@ -107,6 +122,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
url: resultUrl(queryIndex, ordinal),
title: resultTitle(queryIndex, ordinal),
snippet: resultSnippet(queryIndex, ordinal),
summary: resultSummary(queryIndex, ordinal),
content: '', site_name: null, site_icon: null,
date: resultPageAge(ordinal),
})),
@@ -212,16 +228,18 @@ describe('web e2e: opt-in Search API web search', () => {
const rendered = message.content.filter(block => block.type === 'text').map(block => block.text).join('')
// The tool interleaves sources from both seam results before applying the
// combined cap, so each query remains represented in model-visible output.
for (const source of KEPT_SOURCES) {
expect(rendered).toContain(`[${source.title}](${source.url})`)
for (const line of KEPT_SOURCE_LINES) {
expect(rendered).toContain(line)
}
expect(rendered).not.toContain(resultSnippet(0, 1))
for (const url of DROPPED_SOURCE_URLS) {
expect(rendered).not.toContain(url)
}
expect(rendered).toContain(
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
)
expect(searchResult.data.meta).toMatchObject({
// The card keeps the display snippet; the summary reaches only the model.
expect(searchResult.data.meta).toEqual({
sources: KEPT_SOURCES,
truncated: true,
})
+6 -6
View File
@@ -18,14 +18,14 @@
en: b9eeb18645121a8b
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
en: f4dde8809a713bad
zh: ccbee814b4e8b4b7
/deepseek-harness-architecture/events:
en: e89802f066e4955d
zh: ea2fb4bef94e086d
en: c00467ff3a210ef5
zh: 7619d7cca723fa6b
/deepseek-harness-architecture/turn-flow:
en: 657968cf7b76adb5
zh: 8c0fd8d12e2d4aa3
en: 7a2a9d68e4fa1fe4
zh: 67f40bac3d1959cc
/deepseek-harness-architecture/session-log:
en: f1729d805c20df36
zh: 6b71b6039663e2e7
+3 -1
View File
@@ -61,6 +61,7 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes
| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` |
| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
@@ -70,6 +71,7 @@ Events are the extension points, and picking the right domain is the first decis
- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
@@ -105,7 +107,7 @@ turn/end
`turn/*`, `step/*`, `system/message`, `user/message`, `assistant/message`, `assistant/attempt`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/assistant-stream` publishes process-local start, transient chunk, and end frames. The loop commits the complete compact stream as one message or log-only attempt before a committed end frame, and the Web Session-follow adapter is the live event's only remote consumer. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
The factory enters SessionStore and completes `session/created` before setup; `agent/created` follows setup. Disposal commits closing events before removal.
Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal.
One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
+3 -1
View File
@@ -61,6 +61,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
| [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
| [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` |
| [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
| [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
@@ -72,6 +73,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
@@ -109,7 +111,7 @@ turn/end
`turn/*`、`step/*`、`system/message`、`user/message`、`assistant/message`、`assistant/attempt` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/assistant-stream` 发布进程本地 start、瞬态 chunk 与 end frame。loop 会在 committed end frame 前把完整紧凑 stream 提交为一个 message 或仅日志 attempt;Web Session-follow adapter 是该 live event 唯一的远程消费方。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。
工厂在 setup 之前让 Session 进入 SessionStore 并完成 `session/created`;`agent/created` 在 setup 之后发出。销毁在移除之前提交收尾事件。
工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。
输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 753b3da3abc6aeea
zh: aab02ec16c3f744b
en: 3e87315977a89b5a
zh: 626a75f3bebaa39f
+5
View File
@@ -191,6 +191,8 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -298,6 +300,7 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -456,6 +459,7 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -686,6 +690,7 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
+5
View File
@@ -193,6 +193,8 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -300,6 +302,7 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -458,6 +461,7 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -688,6 +692,7 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
+5 -2
View File
@@ -225,7 +225,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -1430,7 +1430,7 @@ export interface Config {
## `@deepseek-ai/dsh-fs-local`
- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
```ts config-catalog
/** Configuration for the local filesystem backend. */
@@ -4258,6 +4258,8 @@ export interface Config {
searchMaxResults?: number
/** Upper bound on queries accepted by one `web_search` call. */
searchMaxQueries?: number
/** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
searchSummaryMaxChars?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
@@ -4696,6 +4698,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+5 -2
View File
@@ -227,7 +227,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -1432,7 +1432,7 @@ export interface Config {
## `@deepseek-ai/dsh-fs-local`
- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
```ts config-catalog
/** Configuration for the local filesystem backend. */
@@ -4260,6 +4260,8 @@ export interface Config {
searchMaxResults?: number
/** Upper bound on queries accepted by one `web_search` call. */
searchMaxQueries?: number
/** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
searchSummaryMaxChars?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
@@ -4698,6 +4700,7 @@ export interface Config {
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
+6 -5
View File
@@ -8,6 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -22,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+6 -5
View File
@@ -10,6 +10,7 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:194`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -24,11 +25,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+8 -3
View File
@@ -41,6 +41,7 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -488,7 +489,6 @@ flowchart TD
pkg_remote_mock --> pkg_typert_protocol
pkg_typert_loader --> pkg_typert_registry
pkg_skill_office --> pkg_skill
pkg_web_fetch_http --> pkg_http_proxy
pkg_web_fetch_http --> pkg_timeout
pkg_web_fetch_http --> pkg_web
pkg_web_search_exa --> pkg_launch_environment
@@ -640,6 +640,9 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1272,6 +1275,7 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1596,7 +1600,7 @@ flowchart TD
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1651,6 +1655,7 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1769,7 +1774,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+8 -3
View File
@@ -43,6 +43,7 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -490,7 +491,6 @@ flowchart TD
pkg_remote_mock --> pkg_typert_protocol
pkg_typert_loader --> pkg_typert_registry
pkg_skill_office --> pkg_skill
pkg_web_fetch_http --> pkg_http_proxy
pkg_web_fetch_http --> pkg_timeout
pkg_web_fetch_http --> pkg_web
pkg_web_search_exa --> pkg_launch_environment
@@ -642,6 +642,9 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1274,6 +1277,7 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1598,7 +1602,7 @@ flowchart TD
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1653,6 +1657,7 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1771,7 +1776,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+4 -4
View File
@@ -1347,8 +1347,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 41c03b03d1bd5a44
zh: 1b0e430af6c140b3
en: 324d7ebffee10aad
zh: 31f7b8a18571665a
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1833,8 +1833,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: 53b7abac679ee252
zh: 63eb6a015986ef6d
en: db30e659a1cc4c06
zh: 8585ea4c8178ab37
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+2 -2
View File
@@ -5583,7 +5583,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -23574,7 +23574,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:406",
"packages/api/session-controller/src/types.ts:412",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:21",
@@ -24096,7 +24096,7 @@
"digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:415"
"packages/api/session-controller/src/types.ts:421"
]
},
{
+6 -3
View File
@@ -6,8 +6,8 @@
en: f6ae07d8902e8edf
zh: c92fef531c6da331
/core/the-spine-package-by-package:
en: c90cb23528ddb6c8
zh: 62724b7255b586b6
en: 60926dc99f41024d
zh: f40370b034f91463
/core/creation-and-ownership:
en: e516e14228c6bd35
zh: dba25d7da1d5a44e
@@ -16,7 +16,10 @@
zh: 25bc9cb60ee1520a
/core/initiating-agent:
en: 6c8c47ac78de2dde
zh: c9d527a25a85d1d9
zh: acde99c8808ea891
/core/messages-from-outside-an-agent:
en: cf087fd9ff5edb87
zh: c3bb56b17cd76c52
/core/interception-decisions:
en: 6fccceb0a5f1cad9
zh: f43c30c2386d8cc8
+125 -1
View File
@@ -15,9 +15,10 @@ A turn flows through the six packages in one loop: the driver in [`agent-loop`](
| `tools/` | The scoped tool registry and guarded execution pipeline (`ctx.tools`) | [tools.md](tools.md) |
| `agent/` | The `Agent` interface, live registry, initiator scope, and `agent/*` event vocabulary (`ctx.agents`) | this page |
| `agent-loop/` | The concrete driver implementing the public `Agent` contract (`ctx.agentLoop`) | this page |
| `agent-controller/` | Delivery of messages from outside an Agent through the `agent-controller/admission` stage (`ctx.agentController`) | this page |
| `scope/` | The scoped-registration primitive the registries and loop build per-agent scoping on | [scope.md](scope.md) |
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. `agent-controller` sits in front of the loop: a sender that brings a message on someone's behalf delivers it through `ctx.agentController` instead of appending to the inbox itself. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
## Creation and ownership
@@ -305,6 +306,65 @@ The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, ch
The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules.
## Messages from outside an Agent
A sender that brings a message on someone's behalf, such as the Web Session Remote, calls `ctx.agentController.deliver` from [`agent-controller`](../../packages/core/agent-controller/README.md) instead of `followup()` or `steer()`. The controller prepares the content in the Agent's delivery order, asks the `agent-controller/admission` waterfall for a decision, and appends an admitted message; messages the Agent's own machinery produces enter without the stage. The [delivery decision](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) explains why the stage lives outside `agent`.
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
A listener admits by returning the decision of `next()`, holds the message by awaiting before `next()`, and refuses it by returning a `reject` decision:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
## Interception decisions
Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result.
@@ -437,6 +497,33 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1099,6 +1186,43 @@ Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+127 -1
View File
@@ -15,9 +15,10 @@
| `tools/` | 带作用域的工具注册表与受保护的执行流水线(`ctx.tools`) | [tools.md](tools.zh.md) |
| `agent/` | `Agent` 接口、实时注册表、发起者作用域与 `agent/*` 事件词汇(`ctx.agents`) | 本页 |
| `agent-loop/` | 实现公开 `Agent` 约定的具体 driver(`ctx.agentLoop`) | 本页 |
| `agent-controller/` | 外部消息经 `agent-controller/admission` 阶段投递进 Agent(`ctx.agentController`) | 本页 |
| `scope/` | 注册表与循环用于构建按 agent 作用域的注册原语 | [scope.md](scope.zh.md) |
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。`agent-controller` 位于循环之前:代表他人送来消息的发送方经 `ctx.agentController` 投递,而不是自己写入收件箱。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
<a id="creation-and-ownership"></a>
@@ -311,6 +312,67 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有
`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。
<a id="messages-from-outside-an-agent"></a>
## 来自 Agent 外部的消息
代表他人送来消息的发送方(例如 Web Session Remote)调用 [`agent-controller`](../../packages/core/agent-controller/README.zh.md) 的 `ctx.agentController.deliver`,而不是 `followup()` 或 `steer()`。控制器按该 Agent 的投递顺序准备内容,向 `agent-controller/admission` waterfall 请求决定,再放入被接纳的消息;Agent 自身机制产生的消息不经过这个阶段。[投递决策](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)说明了这个阶段为什么不放在 `agent` 里。
源码:[`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
监听器返回 `next()` 的决定即放行,在 `next()` 之前 await 即暂留消息,返回 `reject` 决定即拒绝:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
<a id="interception-decisions"></a>
## 拦截决策
@@ -449,6 +511,33 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.zh.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1111,6 +1200,43 @@ Types: [Scoped](scope.zh.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+6 -2
View File
@@ -935,10 +935,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+6 -2
View File
@@ -939,10 +939,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+4 -4
View File
@@ -18,11 +18,11 @@
en: da78eb96d684ad47
zh: 4b726cc04c92ef13
/web-access/fetch-network-policy:
en: 261fce9249be8b2b
zh: bf6abf77679aca6d
en: 6851874be5841d90
zh: dc085bf3b82d77df
/web-access/errors:
en: b07c04f462b4a0d9
zh: de5f09cd2fb0324c
/web-access/the-service:
en: c81fdcdfd6e22861
zh: ac906c9148942041
en: c9e7dc85c08e64e1
zh: 277c52aac58f42dc
+7 -4
View File
@@ -54,15 +54,18 @@ interface WebSearchResult {
```ts type-equiv
/**
* One citeable source. A source always has a URL; `title`, `snippet`, and
* `publishedAt` are optional because not every provider returns them — forcing
* One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
* and `publishedAt` are optional because not every provider returns them — forcing
* adapters to invent them would make the seam lie (Perplexity citations may be
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
*/
interface WebSearchSource {
readonly url: string
readonly title?: string
/** Short excerpt suited to display. */
readonly snippet?: string
/** Longer excerpt suited to model reading; consumers bound its length. */
readonly summary?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */
readonly publishedAt?: string
}
@@ -128,7 +131,7 @@ Selection never depends on registration, config, or HMR order: a capability has
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.
The HTTP provider does not restrict destination addresses: `web_fetch` reaches loopback, private-network, and cloud metadata hosts like any other host, and a model can send data to any URL it can reach. A cross-origin redirect requires a new tool call. A deployment that must keep the model away from private destinations needs network confinement, a `tools/pre-execute` policy, or no fetch tool.
## Errors
@@ -136,7 +139,7 @@ The HTTP provider resolves each actual request, rejects non-public answers inclu
## 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, 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.
`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, sends each request through the installed proxy policy, repeats URL validation 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 -->
+7 -4
View File
@@ -54,15 +54,18 @@ interface WebSearchResult {
```ts type-equiv
/**
* One citeable source. A source always has a URL; `title`, `snippet`, and
* `publishedAt` are optional because not every provider returns them — forcing
* One citeable source. A source always has a URL; `title`, `snippet`, `summary`,
* and `publishedAt` are optional because not every provider returns them — forcing
* adapters to invent them would make the seam lie (Perplexity citations may be
* URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display.
*/
interface WebSearchSource {
readonly url: string
readonly title?: string
/** Short excerpt suited to display. */
readonly snippet?: string
/** Longer excerpt suited to model reading; consumers bound its length. */
readonly summary?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */
readonly publishedAt?: string
}
@@ -128,7 +131,7 @@ type WebFetchBody =
已交付的 Cordis、Code 与 Standard preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。文件 sandbox preset 不管辖 Web 网络访问。需要确认步骤的部署必须添加 `tools/pre-execute` 策略或禁用抓取。
HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前缀抵达私有 IPv4 在内的非公开结果,固定已验证的地址集合,并在每次同源重定向时重复强制执行。跨源重定向需要新的工具调用和新的公开地址校验。这些检查会阻止通过 SSRF 访问非公开目的地址,但不会阻止模型把数据发送到公开 URL。
HTTP 提供方不限制目的地址:`web_fetch` 会像访问其他主机一样访问 loopback、内网与云元数据主机,模型也可以把数据发送到它能访问的任何 URL。跨源重定向需要新的工具调用。需要阻止模型访问私有目的地址的部署必须使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
## 错误
@@ -136,7 +139,7 @@ HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前
## 服务
`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、对每个 hostname 只解析一次、拒绝包含任一非公开 IPv4/IPv6 目的地址或经当前前缀转换到非公开 IPv4 的 NAT64 地址的解析结果、把请求连接固定到已验证地址、对每一次同源重定向跳转重复这些校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。
`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、经已安装的代理策略发送每个请求、对每一次同源重定向跳转重复 URL 校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md
/:
en: 8d967f90aee1902e
zh: 30735df66cafe05d
/web-fetch-reaches-private-destinations:
en: 8c157d5137d0a507
zh: 27cef187efdb1323
/web-fetch-reaches-private-destinations/change:
en: bcbce3e213b81afc
zh: dda264a679117c36
/web-fetch-reaches-private-destinations/migration:
en: e076bdd8bd132ee8
zh: 26d5da33ccc8e37c
@@ -0,0 +1,21 @@
---
kind: upgrade-guide
description: "The HTTP fetch provider no longer rejects non-public destinations, and HttpFetchProvider no longer accepts an address resolver."
---
# `web_fetch` reaches private destinations
English | [中文](guide.zh.md)
## Change
`@deepseek-ai/dsh-web-fetch-http` used to resolve each hostname, reject any loopback, private, link-local, or reserved answer with `WEB_BLOCKED_URL`, and pin the connection to the validated addresses. It now sends every request through the installed proxy policy and the system resolver, so `web_fetch` reaches loopback, private-network, and cloud metadata hosts. Fetches behind TUN-mode proxies with fake-IP DNS work without configuration.
The package no longer exports `HttpFetchResolver`, and the `HttpFetchProvider` constructor takes only `HttpFetchLimits`.
Deployments that relied on the provider to keep the model away from private services are affected, as is code that passed a resolver to `HttpFetchProvider`.
## Migration
1. If the model must not reach private destinations, confine the process's network access, add a `tools/pre-execute` policy that rejects those `web_fetch` URLs, or remove `@deepseek-ai/dsh-tool-web`'s fetch tool from the composition.
2. Replace `new HttpFetchProvider(limits, resolveAddresses)` with `new HttpFetchProvider(limits)`. To direct a test request to a local server, put the server's address in the URL instead of overriding resolution.
3. Confirm the migration with `pnpm run typecheck`; no reference to `HttpFetchResolver` remains.
@@ -0,0 +1,21 @@
---
kind: upgrade-guide
description: "HTTP 抓取提供方不再拒绝非公开目标,HttpFetchProvider 也不再接受地址解析器。"
---
# `web_fetch` 可以访问私有目标
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-web-fetch-http` 以前会解析每个主机名,以 `WEB_BLOCKED_URL` 拒绝任何 loopback、私有、link-local 或保留地址,并把连接固定到已校验地址。现在它通过已安装的代理策略和系统解析器发送每个请求,因此 `web_fetch` 可以访问 loopback、内网与云元数据主机。使用 fake-IP DNS 的 TUN 模式代理之后,抓取无需配置即可工作。
本包不再导出 `HttpFetchResolver`,`HttpFetchProvider` 构造函数只接受 `HttpFetchLimits`。
依赖该提供方阻止模型访问私有服务的部署会受影响;向 `HttpFetchProvider` 传入解析器的代码也会受影响。
## 迁移
1. 如果模型不得访问私有目标,请限制进程的网络访问、添加拒绝这些 `web_fetch` URL 的 `tools/pre-execute` 策略,或从组合中移除 `@deepseek-ai/dsh-tool-web` 的抓取工具。
2. 把 `new HttpFetchProvider(limits, resolveAddresses)` 替换为 `new HttpFetchProvider(limits)`。如需把测试请求指向本地服务器,请把服务器地址写进 URL,而不是覆盖解析。
3. 用 `pnpm run typecheck` 确认迁移完成,且不再引用 `HttpFetchResolver`。
+2 -2
View File
@@ -15,8 +15,8 @@
en: 17867305a3e1b52a
zh: 876fedf1f6f563e4
/configure-models/add-a-custom-model-api/discover-models:
en: 97318c5a115199ff
zh: 0c455e4232cf0265
en: ec10ba53a00eddc6
zh: 857373a4e0fefee4
/configure-models/select-a-model:
en: 6a94264c99535caf
zh: f97bb0dc6a40c0d3
+1 -1
View File
@@ -32,7 +32,7 @@ The Provider ID is permanent because requests, saved sessions, model defaults, a
### Discover models
Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: search, tick the models you want, and choose **Add selected**. Nothing is stored until you save or create the provider.
Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: models already in the list start ticked, so search, tick the models to add, untick the ones to remove, and choose **Update list**. Configured models the endpoint does not list stay. Nothing is stored until you save or create the provider.
Discovery reads the listing formats common gateways publish, but not every endpoint answers in one of them, so treat it as a convenience rather than a guarantee: when it fails or lists nothing, add the model ids by hand and they work just the same. A built-in provider is always answered from the installed catalog, even when its base URL points at a gateway, so fetch through a custom provider to see what the gateway really serves.
+1 -1
View File
@@ -32,7 +32,7 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
### 探测模型
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。
在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,列表中已有的模型默认勾选,搜索后勾选要添加的模型、取消勾选要移除的模型,再点**更新列表**。端点未列出的已配置模型保持不变。保存或创建提供商之前不会存储任何内容。
探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: 8fcfa14545bd9d1c
zh: c10c2bba792699ef
en: fe242dda6579245e
zh: cbd8c012135623de
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. A prompt enters the Agent through [`ctx.agentController.deliver`](../../core/agent-controller/README.md#use-this-package), which runs the receipt resolution and attachment storage in the Agent's delivery order and binds the receipts immediately before the message enters the inbox, so the call stays open while an `agent-controller/admission` listener holds it. An image prompt takes the Session's image admission turn when its preparation starts and keeps it until the prompt settles, so a model selection lands either before its image check or after it entered the inbox, and waits while a listener holds that image prompt. A refusal returns `session/prompt-rejected` with the listener's `reason` and `presented`, a listener failure returns the same code with reason `agent-controller/admission-failed`, and an Agent disposed meanwhile returns `session/not-found`. A client that predates `session/prompt-rejected` shows it as an ordinary failure with its message and code. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
+1 -1
View File
@@ -33,7 +33,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。提示词经 [`ctx.agentController.deliver`](../../core/agent-controller/README.zh.md#use-this-package) 进入 Agent:控制器按该 Agent 的投递顺序完成凭证解析与附件存储,并在消息进入收件箱前一刻绑定凭证,因此 `agent-controller/admission` 监听器暂留它期间调用保持未完成。图片提示词在开始准备时取得该 Session 的图片准入顺位,并持有到提示词结束,因此模型切换要么发生在它的图片检查之前,要么发生在它进入收件箱之后;监听器暂留这条图片提示词期间,模型切换会等待。被拒绝时返回 `session/prompt-rejected`,并带上监听器给出的 `reason` 与 `presented`;监听器失败时返回同一错误码,reason 为 `agent-controller/admission-failed`;期间 Agent 被销毁则返回 `session/not-found`。早于 `session/prompt-rejected` 的旧客户端把它显示为普通失败,带消息与错误码。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
@@ -80,6 +80,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
"@deepseek-ai/dsh-api-gateway": "workspace:*",
@@ -125,6 +126,7 @@
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
@@ -387,6 +387,27 @@ export class ApiSessionAgentController {
return result
}
/**
* Take the image admission turn for one Agent: wait until earlier image
* admissions and model selections settled, then hold the turn until the
* returned release runs.
* @param agent - live Agent that owns the serialization chain.
* @param signal - the waiting caller's cancellation; a caller cancelled by its turn passes the turn on.
* @returns the release, which the caller runs once its image prompt settled.
* @throws the cancellation reason when the caller was cancelled by its turn.
*/
acquireImageAdmission(agent: Agent, signal: AbortSignal): Promise<() => void> {
const previous = this.imageAdmissionChains.get(agent) ?? Promise.resolve()
const held = Promise.withResolvers<undefined>()
this.imageAdmissionChains.set(agent, previous.then(() => held.promise))
const release = (): void => { held.resolve(undefined) }
return previous.then(() => {
if (signal.aborted) release()
signal.throwIfAborted()
return release
})
}
/**
* Resolve the preset id and pre-publication Agent setup for a create or resume.
* @param presetId - requested preset or the configured default when omitted.
+89 -42
View File
@@ -5,14 +5,15 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type { ADMISSION_AGENT_GONE, ADMISSION_REJECTED_CODE, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type {
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
} from '@deepseek-ai/dsh-attachment'
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import type { PromptFileBinding } from '@deepseek-ai/dsh-client-file-upload'
import {
ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage,
ReasoningEffortId, assistantStreamChunks, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
@@ -310,56 +311,72 @@ export class SessionCommandController {
}
/**
* Admit one prompt after Agent and attachment validation; the Remote method
* has already validated the content and built the durable message source.
* Deliver one prompt through the Agent controller, whose admission stage
* may hold or refuse it; the Remote method has already validated the
* content and built the durable message source. The controller runs this
* Session's content preparation (image model check, upload receipts,
* attachment storage) in the Agent's delivery order and binds the upload
* receipts immediately before the message enters the inbox. A repeated
* request identity is acknowledged without delivering it again. An image
* prompt takes the Session's image admission turn when its preparation
* starts and keeps it until the prompt settles, so a model selection lands
* either before its image check or after it entered the inbox; a selection
* waits while a listener holds that image prompt.
* @param request - Session identity, prompt content, and delivery mode.
* @param source - the `user-rpc` source recorded on the accepted message.
* @param signal - the requesting client's cancellation; it stops a prompt that waits for its turn or is held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
async prompt(request: SessionPromptRequest, source: MessageSource): Promise<SessionPromptValue> {
async prompt(request: SessionPromptRequest, source: MessageSource, signal: AbortSignal): Promise<SessionPromptValue> {
const agent = await this.resolveAgent(request.sessionId)
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
const hasImage = request.content.some(part => part.type === 'image')
const admit = async (): Promise<SessionPromptValue> => {
try {
if (hasImage) {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
const uploads: { receiptIds: readonly FileUploadReceiptId[]; binding?: PromptFileBinding } = { receiptIds: [] }
const imageTurn: { release?: () => void } = {}
try {
await this.ctx.agentController.deliver(agent, {
mode: request.mode === 'steer' ? 'steer' : 'followup',
source,
signal,
prepare: async (preparing) => {
if (hasImage) {
// Taken in the Agent's delivery order, so a waiting image prompt keeps its place among the others.
imageTurn.release = await this.agents.acquireImageAdmission(agent, preparing)
await this.requireImageInput(agent)
}
}
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
)
const content = await this.ctx.attachments.admitPromptContent(admission.content)
const message: UserMessage = createUserMessage({ content, source })
if (this.ctx.agents.get(agent.id) !== agent) {
throw new RemoteError(
'session/not-found',
`session "${agent.id}" was disposed during prompt admission`,
{ sessionId: agent.id },
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
)
}
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
binding.commit()
} catch (error) {
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof AttachmentError) {
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
return { accepted: true }
uploads.receiptIds = admission.receiptIds
return await this.ctx.attachments.admitPromptContent(admission.content)
},
beforeInsert: () => {
uploads.binding = this.ctx.fileUploads.bindPrompt(agent, uploads.receiptIds, request.requestId)
},
})
uploads.binding?.commit()
} catch (error) {
throw promptFailure(agent, error, signal)
} finally {
uploads.binding?.[Symbol.dispose]()
imageTurn.release?.()
}
return { accepted: true }
}
/** Refuse an image prompt when the Session's current model does not declare image input. */
private async requireImageInput(agent: Agent): Promise<void> {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
}
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
}
private async requireModel(selection: Pick<AgentModelSelection, 'provider' | 'model'>): Promise<void> {
@@ -560,6 +577,36 @@ export class SessionCommandController {
}
}
// Typed by the agent-controller constants, so a changed value fails this module's build while it imports no agent-controller value.
const ADMISSION_REJECTED: typeof ADMISSION_REJECTED_CODE = 'AGENT_ADMISSION_REJECTED'
const AGENT_GONE: typeof ADMISSION_AGENT_GONE = 'agent-controller/agent-gone'
/** Recognize an admission refusal by the code that the error of every copy of the agent-controller module carries. */
function isAdmissionRejected(error: unknown): error is AgentAdmissionRejected {
return error instanceof Error && 'code' in error && error.code === ADMISSION_REJECTED
}
/**
* Map a failed prompt admission onto the Session Remote's failures: Remote
* failures and the client's own cancellation pass unchanged, and an admission
* refusal keeps its reason and whether its plugin presents it.
*/
function promptFailure(agent: Agent, error: unknown, signal: AbortSignal): unknown {
if (remoteErrorOf(error) !== undefined || signal.aborted) return error
if (isAdmissionRejected(error)) {
if (error.reason === AGENT_GONE) {
return new RemoteError('session/not-found', `session "${agent.id}" was disposed during prompt admission`, {
sessionId: agent.id,
})
}
return new RemoteError('session/prompt-rejected', error.message, { reason: error.reason, presented: error.presented })
}
if (error instanceof AttachmentError) {
return new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
return new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
function resolvePromptFileReceipts(
content: SessionPromptRequest['content'],
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
+8 -3
View File
@@ -117,6 +117,7 @@ export interface SessionControllerInternals {
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
'agentController',
'agentDefaultModel',
'agents',
'attachments',
@@ -458,10 +459,14 @@ export class SessionController extends TypertRemoteService {
}
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
@@ -487,7 +492,7 @@ export class SessionController extends TypertRemoteService {
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
...(principal === undefined ? {} : { principal }),
}
return this.commands.prompt(request, source)
return this.commands.prompt(request, source, signal)
}
/**
@@ -212,6 +212,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/projections-unavailable': Record<string, never>
'session/writer-held': { readonly sessionId: SessionId }
'session/agent-busy': { readonly reason: string }
'session/prompt-rejected': {
/** Code of the refusal, owned by the plugin that refused, such as `forgePanel/worktree-missing`. */
readonly reason: string
/** Whether the refusing plugin shows its own explanation, so the client adds no notice. */
readonly presented: boolean
}
'session/invalid-time-zone': { readonly value: string }
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'agent-preset/conflict': {
@@ -1,5 +1,6 @@
import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
@@ -38,6 +39,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentController)
await ctx.plugin(CommandRuntime)
const session = ctx.sessions.create(SESSION, {
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
@@ -99,6 +101,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
} as unknown as ApiSessionAgentController
// Image prompts take their admission turn at once; no model selection competes in these tests.
Object.assign(agents, { acquireImageAdmission: () => Promise.resolve(() => {}) })
const uploads = new FileUploads(ctx)
if (uploadRoute === undefined) throw new Error('file upload route was not registered')
return {
@@ -117,6 +121,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */
const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId }
/** A client that stays connected for the whole prompt. */
const SIGNAL = new AbortController().signal
function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
return {
@@ -168,7 +174,7 @@ describe('Session file uploads', () => {
{ type: 'file', receiptId: receipt.receiptId },
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'text', text: 'read it' },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
expect(followup).toHaveBeenCalledTimes(1)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -195,7 +201,7 @@ describe('Session file uploads', () => {
name: 'huge.bin',
}))
expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{ type: 'file', attachment: receipt.file },
])
@@ -222,7 +228,7 @@ describe('Session file uploads', () => {
await expect(controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
expect(followup).not.toHaveBeenCalled()
expect(saveImages).not.toHaveBeenCalled()
})
@@ -320,7 +326,7 @@ describe('Session file uploads', () => {
const { controller, uploads, agent } = await uploadHarness()
uploads.retirePrompt(agent, 'not-staged')
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
uploads.retirePrompt(agent, 'other-request')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
@@ -335,7 +341,7 @@ describe('Session file uploads', () => {
{ data: 'AAAA' },
new AbortController().signal,
)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE, SIGNAL)
first.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'extension event' }],
source: { kind: 'user', rpcId: 1 } as never,
@@ -360,9 +366,9 @@ describe('Session file uploads', () => {
it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
const { controller, agent, followup } = await uploadHarness()
const request = promptRequest([{ type: 'text', text: 'once' }])
await controller.prompt(request, USER_SOURCE)
await controller.prompt(request, USER_SOURCE, SIGNAL)
agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
expect(followup).toHaveBeenCalledOnce()
})
@@ -379,7 +385,7 @@ describe('Session file uploads', () => {
source: { kind: 'user', rpcId: request.requestId },
}), { surfaceOp: 'append' })
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
expect(followup).not.toHaveBeenCalled()
})
@@ -389,7 +395,7 @@ describe('Session file uploads', () => {
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
await disposeAgent()
admitted.resolve([{
@@ -401,7 +407,7 @@ describe('Session file uploads', () => {
it('rejects when a previously bound receipt retires during image admission', async () => {
const { controller, uploads, agent, saveImages } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt({
@@ -410,7 +416,7 @@ describe('Session file uploads', () => {
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE)
}, USER_SOURCE, SIGNAL)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
uploads.retirePrompt(agent, 'req-1')
admitted.resolve([{
@@ -424,12 +430,12 @@ describe('Session file uploads', () => {
it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt({
...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
}, USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
})
@@ -440,7 +446,7 @@ describe('Session file uploads', () => {
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt(promptRequest([
{ type: 'file', receiptId: receipt.receiptId },
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
})
@@ -448,7 +454,7 @@ describe('Session file uploads', () => {
it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
const queued = followup.mock.calls[0]?.[0] as UserMessage
agent.inbox.append('next-turn', queued)
expect(await controller.updateQueue({
@@ -469,7 +475,7 @@ describe('Session file uploads', () => {
await controller.prompt(promptRequest([
{ type: 'file', receiptId: first.receiptId },
{ type: 'file', receiptId: second.receiptId },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -0,0 +1,217 @@
/**
* Session prompts through the Agent controller's admission stage: how the
* `session/prompt` Remote reports a held, refused, or failed admission to the
* client.
*/
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { createSessionTestRemote } from './test-remote.ts'
let nextRequestId = 1
async function harness(options: { readonly refusal?: Error } = {}) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create()
const inbox = createInboxStub()
const deliver = (target: 'next-turn' | 'next-step', message: UserMessage): void => {
if (options.refusal !== undefined) throw options.refusal
inbox.append(target, message)
}
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
status: 'idle',
ctx,
send: () => {},
followup: (message) => { deliver('next-turn', message) },
steer: (message) => { deliver('next-step', message) },
inject: () => {},
cancel: () => {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const unregister = await ctx.agents.register(agent)
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
})
const prompt = (text: string, mode: SessionPromptRequest['mode'] = 'queue', requestId?: SessionRequestId) => ({
requestId: requestId ?? `admission-${String(nextRequestId++)}` as SessionRequestId,
sessionId: session.id,
mode,
content: [{ type: 'text' as const, text }],
})
return { ctx, agent, inbox, remote, prompt, unregister }
}
function textsOf(messages: readonly UserMessage[]): string[] {
return messages.map(message => message.content.map(block => block.type === 'text' ? block.text : '').join(''))
}
describe('Session prompt admission', () => {
it('admits a queued prompt into the next turn and a steered one into the next step', async () => {
const { ctx, agent, inbox, remote, prompt } = await harness()
const admitted: Agent[] = []
ctx.on('agent-controller/admission', async ({ agent: subject }, next) => {
admitted.push(subject)
return await next()
})
expect(await remote.prompt(prompt('later'))).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('now', 'steer'))).toEqual({ ok: true, value: { accepted: true } })
expect(admitted).toEqual([agent, agent])
expect(textsOf(inbox.nextTurn)).toEqual(['later'])
expect(textsOf(inbox.nextStep)).toEqual(['now'])
})
it('keeps the prompt call open while a listener holds it', async () => {
const { ctx, inbox, remote, prompt } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
let settled = false
const pending = remote.prompt(prompt('held')).finally(() => { settled = true })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(settled).toBe(false)
expect(inbox.nextTurn).toEqual([])
release.resolve(undefined)
expect(await pending).toEqual({ ok: true, value: { accepted: true } })
expect(textsOf(inbox.nextTurn)).toEqual(['held'])
})
it('returns a refusal with its reason and whether its plugin presents it, and keeps the inbox untouched', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let presented = true
ctx.on('agent-controller/admission', () => Promise.resolve({
kind: 'reject' as const, reason: 'fixture/worktree-missing', message: 'The worktree was removed.', presented,
}))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
presented = false
expect(await remote.prompt(prompt('refused again'))).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'fixture/worktree-missing', presented: false } },
})
expect(inbox.nextTurn).toEqual([])
})
it('reports a failed listener as a refusal the client explains', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', () => Promise.reject(new Error('listener broke')))
const result = await remote.prompt(prompt('broken'))
expect(result).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'agent-controller/admission-failed', presented: false } },
})
expect(result.ok ? '' : result.error.message).toContain('listener broke')
expect(inbox.nextTurn).toEqual([])
})
it('cancels a held prompt whose client went away', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', async ({ signal }, next) => {
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
return await next()
})
const client = new AbortController()
const pending = remote.prompt(prompt('abandoned'), client.signal)
await new Promise<void>((resolve) => { setImmediate(resolve) })
client.abort(new Error('client left'))
expect(await pending).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
expect(inbox.nextTurn).toEqual([])
})
it('reports a Session whose Agent was disposed while its prompt was held as not found', async () => {
const { ctx, agent, remote, prompt, unregister } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const pending = remote.prompt(prompt('orphaned'))
await new Promise<void>((resolve) => { setImmediate(resolve) })
await unregister()
release.resolve(undefined)
expect(await pending).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('maps a refusal from another copy of the agent-controller module by its code', async () => {
const { ctx, agent, remote, prompt } = await harness()
// Another copy of the module raises the same fields from its own class.
const refusal = (reason: string): Error => Object.assign(new Error('The worktree was removed.'), {
name: 'AgentAdmissionRejected', code: 'AGENT_ADMISSION_REJECTED', reason, presented: true,
})
vi.spyOn(ctx.agentController, 'deliver')
.mockRejectedValueOnce(refusal('fixture/worktree-missing'))
.mockRejectedValueOnce(refusal('agent-controller/agent-gone'))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
expect(await remote.prompt(prompt('orphaned'))).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('acknowledges a repeated request without admitting it again', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let admissions = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
admissions += 1
return await next()
})
const first = prompt('once')
expect(await remote.prompt(first)).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('once', 'queue', first.requestId))).toEqual({ ok: true, value: { accepted: true } })
expect(admissions).toBe(1)
expect(textsOf(inbox.nextTurn)).toEqual(['once'])
})
it('reports the Agent refusing to queue a prompt as busy', async () => {
const { remote, prompt } = await harness({ refusal: new Error('inbox closed') })
expect(await remote.prompt(prompt('rejected'))).toMatchObject({
ok: false,
error: { code: 'session/agent-busy', details: { reason: 'Error: inbox closed' } },
})
})
})
@@ -137,6 +137,46 @@ async function harness(logged?: {
return { ctx, agent, sessionId: session.id }
}
/** An image store whose saved attachments are one-pixel PNGs. */
function provideImageStore(ctx: Context): void {
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
validateImage: () => Promise.resolve(),
saveImage: (input: { data: Uint8Array; mediaType: 'image/png' }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`, mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1,
}),
}
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
}
/** A queued prompt with its text, after a one-pixel image when `withImage` is set. */
function imagePrompt(sessionId: SessionId, text: string, withImage: boolean): SessionPromptRequest {
return promptRequest({
sessionId,
mode: 'queue',
content: [
...withImage ? [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }] : [],
{ type: 'text' as const, text },
],
})
}
function textOf(message: UserMessage): string {
return message.content.map(block => block.type === 'text' ? block.text : '').join('')
}
/** Let pending prompts run as far as they can. */
async function settle(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function expectValue<T>(result: { ok: true; value: T } | { ok: false }): T {
if (!result.ok) throw new Error('expected successful response')
return result.value
@@ -313,6 +353,110 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('admits image prompts in arrival order behind an image prompt that a listener holds', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async ({ message }, next) => {
if (textOf(message) === 'held') await release.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const prompts = [
remote.prompt(imagePrompt(sessionId, 'held', true)),
remote.prompt(imagePrompt(sessionId, 'second image', true)),
remote.prompt(imagePrompt(sessionId, 'third text', false)),
]
await settle()
expect(followed).toEqual([])
release.resolve(undefined)
expect((await Promise.all(prompts)).map(result => result.ok)).toEqual([true, true, true])
expect(followed).toEqual(['held', 'second image', 'third text'])
await ctx.fiber.dispose()
})
it('keeps a model selection behind the image prompt being admitted but not behind one still queued', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
provideImageStore(ctx)
const events: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { events.push(`admitted ${textOf(message)}`) } })
const releases = new Map([['held image', Promise.withResolvers<undefined>()], ['held text', Promise.withResolvers<undefined>()]])
ctx.on('agent-controller/admission', async ({ message }, next) => {
await releases.get(textOf(message))?.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const select = () => remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
.then((result) => { events.push('selected'); return result })
// A held image prompt was checked against the current model, so the selection waits for its append.
const heldImage = remote.prompt(imagePrompt(sessionId, 'held image', true))
await settle()
const first = select()
await settle()
expect(events).toEqual([])
releases.get('held image')?.resolve(undefined)
expect((await heldImage).ok).toBe(true)
expect((await first).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected'])
// An image prompt queued behind a held text prompt has not been checked yet, so the selection does not wait for it.
const heldText = remote.prompt(imagePrompt(sessionId, 'held text', false))
const queuedImage = remote.prompt(imagePrompt(sessionId, 'queued image', true))
await settle()
expect((await select()).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected', 'selected'])
releases.get('held text')?.resolve(undefined)
expect((await heldText).ok).toBe(true)
expect(await queuedImage).toMatchObject({
ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
})
expect(events).toEqual(['admitted held image', 'selected', 'selected', 'admitted held text'])
await ctx.fiber.dispose()
})
it('passes the image admission turn on when the sender of a waiting image prompt leaves', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const listing = Promise.withResolvers<readonly LlmModelInfo[]>()
ctx.llm.registerAdapter(['gated'], new class extends CatalogAdapter {
override listModels(): Promise<readonly LlmModelInfo[]> {
return listing.promise
}
}('Gated', []))
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
// The selection holds the image admission turn while the provider lists its models.
const selection = remote.selectModel(request({ sessionId, provider: 'gated', model: 'slow' }))
await settle()
const sender = new AbortController()
const leaving = remote.prompt(imagePrompt(sessionId, 'left', true), sender.signal)
await settle()
sender.abort(new Error('client left'))
expect(await leaving).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
listing.resolve([{ provider: 'gated', id: 'slow', name: 'Slow' }])
expect((await selection).ok).toBe(true)
expect((await remote.prompt(imagePrompt(sessionId, 'after', true))).ok).toBe(true)
expect(followed).toEqual(['after'])
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
@@ -31,6 +31,7 @@ import {
remoteErrorOf,
type RemoteResult,
} from '@deepseek-ai/dsh-typert-protocol'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '../src/index.ts'
import type {
@@ -293,6 +294,7 @@ async function installControllers(
} as never)
}
installSessionReadTestServices(ctx)
if (ctx.get('agentController') === undefined) new AgentController(ctx)
if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
class TestController extends SessionController {
@@ -32,6 +32,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-controller"
},
{
"path": "../../core/agent-default-model"
},
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0f971f7ac7c09139
zh: ceb866da681c709c
/deepseek-ai-dsh-base/use-this-package/what-you-get:
en: 5bfb7755e987862c
zh: 1f0e9ea7a826a509
en: 2ef2653d55107eec
zh: 4936c0fff94b1984
/deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform:
en: 2dce12f82d6ee955
zh: d4599eb899a3cd88
+1 -1
View File
@@ -47,7 +47,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode
### What you get
Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval and can reach loopback and private-network hosts. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path.
Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities.
+1 -1
View File
@@ -47,7 +47,7 @@ kind: "package-bundle"
### 你得到什么
开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,并且可以访问 loopback 与内网主机。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。
`subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。
+5
View File
@@ -82,6 +82,11 @@
- id: agent
name: '@deepseek-ai/dsh-agent'
# Delivers messages from outside an Agent into its inbox after the admission
# stage; entry points such as the Web Session Remote deliver through it.
- id: agent-controller
name: '@deepseek-ai/dsh-agent-controller'
- id: plugin-package-inventory-deepseek
name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
+1
View File
@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-hmr": "workspace:*",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-attachment-local": "workspace:*",
@@ -63,5 +63,5 @@
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
border-color: transparent;
--dsh-button-outline-color: transparent;
}
+2
View File
@@ -32,6 +32,7 @@
"inject": [
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-api-workspace-controller",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-input-trigger",
@@ -62,6 +63,7 @@
"@deepseek-ai/dsh-api-workspace-files": "workspace:*",
"@deepseek-ai/dsh-attachment": "workspace:*",
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-client-connection": "workspace:*",
"@deepseek-ai/dsh-client-locale": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-client-ui-approval": "workspace:*",
+17 -11
View File
@@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client'
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
// The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
// Type-only service and declaration merges used by the apply world.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {
ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected,
QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps,
@@ -74,7 +75,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
/** Services required by the Chat target and its presentation registrations. */
export const inject = [
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
'configForms', 'remote', 'remote.session', 'sidebarRight',
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight',
]
/**
@@ -203,7 +204,8 @@ export function apply(ctx: Context): void {
}),
}, TranscriptViewRow))
const nodeSources = (binding: SessionBinding): ChatFlowDataInjected => {
const connectionState = (ctx.get('connection') as ConnectionHandle).state
const nodeSources = (binding: SessionBinding): Pick<ChatViewInjected, 'hooks' | 'keyedHooks'> => {
const chat = chatSource(binding)
const conversation = ctx.uiConversation.binding(binding)
return {
@@ -225,7 +227,8 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId): ChatFlowDataInjected => {
const binding = ctx.sessions.binding(sessionId)
if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
return nodeSources(binding)
const sources = nodeSources(binding)
return { ...sources, hooks: { ...sources.hooks, connectionState } }
},
}, ChatFlow))
@@ -250,19 +253,22 @@ export function apply(ctx: Context): void {
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
// Files open in the right Sidebar, not in a desktop application: the
// content stays in the product, beside the conversation that produced
// it. A relative path, or an absolute one inside the session's
// workspace, is addressed under this session's scope,
// `dsh-resource://file/session/<id>/<path>`; an absolute path
// elsewhere keeps its absolute spelling in the same Session's address.
// Which tab type claims the
// address is the Sidebar's decision, not this call site's.
// it. A relative path resolves from the Session's working directory
// at click time, which a directory change can move away from the
// workspace root; links from earlier Turns follow later changes. A
// path inside the workspace is addressed under this
// session's scope, `dsh-resource://file/session/<id>/<path>`; an
// absolute path elsewhere keeps its absolute spelling in the same
// Session's address. Which tab type claims the address is the
// Sidebar's decision, not this call site's.
// A line travels as a navigation parameter, not as part of the
// address: the file is one piece of content whether it is opened at
// its top or at line 400, so the same tab is revealed and told where
// to land.
openFile: async (path, options) => {
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const url = fileAddressFor(sessionId, cwd, path)
const current = session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined
const url = fileAddressFor(sessionId, cwd, resolveWorkspacePath(current ?? undefined, path))
if (options?.line === undefined) ctx.sidebarRight.openResource(url)
else ctx.sidebarRight.openResource(url, { params: { line: options.line } })
await Promise.resolve()
@@ -25,12 +25,13 @@ function isTranscriptEcho(item: PendingInput): boolean {
/** Render the flow without adding a DOM parent or changing keyed row positions. */
export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation,
useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState,
useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction,
cwd, openFile, inspectCall, forkAt, loadImage, fileMentions,
}: ChatFlowSlotProps) {
const nodeStore = useChat(snapshot => snapshot.nodes)
const running = useSession(snapshot => snapshot.running)
const disconnected = useConnectionState(state => state === 'disconnected' || state === 'connecting')
const latestTurnAnchor = useChat(snapshot => snapshot.navigation.items().at(-1)?.anchorKey)
const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
const location = node?.location
@@ -85,7 +86,7 @@ export const ChatFlow = memo(function ChatFlow({
// Rows enter before the keyed status and echoes after it, so React never moves its DOM node.
return [
...rows, ...leadingRows, ...openingRows,
...running ? [<RunningStatus key="running" startTime={runningStartTime} t={t} />] : [],
...running ? [<RunningStatus key="running" startTime={runningStartTime} paused={disconnected} t={t} />] : [],
...pendingRows,
]
})
@@ -9,22 +9,24 @@ import css from './ChatView.module.css'
interface RunningStatusProps {
readonly startTime: number | undefined
/** Freeze the shown duration while the Client cannot observe the Session. */
readonly paused: boolean
readonly t: ChatViewSlotProps['t']
}
/**
* Show live elapsed time after the current Turn's content without announcing ticks.
* @param props - Current Turn start time and localized copy.
* @param props - Current Turn start time, pause state, and localized copy.
* @returns the blue running indicator; mount only while the Session is running.
*/
export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
export const RunningStatus = memo(function RunningStatus({ startTime, paused, t }: RunningStatusProps) {
const [now, setNow] = useState(Date.now)
useEffect(() => {
if (startTime === undefined) return
if (startTime === undefined || paused) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [startTime])
}, [startTime, paused])
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
duration: formatRunDuration(Math.max(1000, now - startTime), t).map(part => part.text).join(''),
})
@@ -15,6 +15,7 @@ import type {
PropsRenderFactories, PropsStore, SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client'
import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read.
@@ -325,8 +326,13 @@ export interface ChatViewInjected {
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Existing Session-owned node sources shared by the view and its flow renderer. */
export type ChatFlowDataInjected = Pick<ChatViewInjected, 'hooks' | 'keyedHooks'>
/** Session-owned node sources shared with the view, plus the flow's connection state. */
export type ChatFlowDataInjected = Pick<ChatViewInjected, 'keyedHooks'> & {
hooks: ChatViewInjected['hooks'] & {
/** Client connection lifecycle; the running clock pauses while it is not connected. */
connectionState: ConnectionStateSource
}
}
/** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */
export interface ChatFlowOwnerProps extends Pick<ChatNodeOwnerProps,
@@ -24,8 +24,8 @@
en: 75564701ecd3d1bd
zh: 9c042288a15d441d
/chat-conversation-node-rules/whole-turn-folding:
en: f434416f393641fc
zh: 9ef356d6a264cf05
en: 09314600edba4979
zh: da8d599372c7899c
/chat-conversation-node-rules/display-modes:
en: ada5a60d2c46f6a8
zh: f3eaeb5cb4d9b3de
@@ -103,7 +103,7 @@ Clicking Load older anchors the first visible content item below that button in
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. When the transcript overflows the viewport and follows its bottom, following scrolls keep the indicator in place as output grows, so this slide does not play; admitted steering still moves the indicator past its pending echo, so the steering entrance plays while the page scrolls by the added slot. The steering entrance starts one slot above the indicator's new position rather than where it stood, and its fade-in covers the difference. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. When the transcript overflows the viewport and follows its bottom, following scrolls keep the indicator in place as output grows, so this slide does not play; admitted steering still moves the indicator past its pending echo, so the steering entrance plays while the page scrolls by the added slot. The steering entrance starts one slot above the indicator's new position rather than where it stood, and its fade-in covers the difference. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. While the Client connection is disconnected or reconnecting, the clock holds its last value; after reconnection it shows the elapsed time since the Turn start again. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
The top duration/status control and trigger title use the group-title font size and follow the font-size setting. Completed duration digits use the code font with tabular numerals; running and completed minutes and seconds have no leading zero. The timed running label ends with a space and “···”; completion shows “Completed in”.
@@ -103,7 +103,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,输出增长由跟随滚动吸收,运行指示保持原位,因此这次下滑不播放;被领取的 steering 仍会让运行指示越过其等待中的回显,因此 steering 进场照常播放,同时页面随新增的槽位滚动。steering 进场从运行指示新位置上方一个槽位处开始,而不是从它原来的位置开始,淡入掩盖了这段差距。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,输出增长由跟随滚动吸收,运行指示保持原位,因此这次下滑不播放;被领取的 steering 仍会让运行指示越过其等待中的回显,因此 steering 进场照常播放,同时页面随新增的槽位滚动。steering 进场从运行指示新位置上方一个槽位处开始,而不是从它原来的位置开始,淡入掩盖了这段差距。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。Client 连接断开或重新连接期间,时钟停在最后显示的值;恢复连接后重新显示自 Turn 开始以来的用时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。
@@ -61,6 +61,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
get: (id: string) => id === CHAT_SETTINGS_NAMESPACE ? chatSettings.scope : stubConfigForm().scope,
} as never)
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', layout as never)
const sidebarRight = {
openResource: vi.fn<(address: string) => void>(),
@@ -265,6 +266,25 @@ describe('Chat inject API', () => {
await b.runtime.dispose()
})
it('resolves relative file paths against the Session\'s current working directory', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/proj/.agents/worktrees/w')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
// A leading `./` names the same file, so the Sidebar reuses its tab.
await injected.openFile('./src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/.agents/worktrees/w/src/a.ts')
await injected.openFile('/proj/src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts')
// Outside the workspace, the address carries the absolute path after the Session id.
await b.runtime.sessions.setProjection(ROOT, 'workingDirectory', '/elsewhere')
await injected.openFile('src/a.ts')
expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1//elsewhere/src/a.ts')
await b.runtime.dispose()
})
it('opens message HTTP(S) links in Sidebar Browser tabs', async () => {
const b = await bench()
const { injected } = b.chatViewApi(b.rootReference)
@@ -447,6 +467,8 @@ describe('Chat inject API', () => {
expect(flow.injected.keyedHooks.chatNode('missing')).toBe(injected.keyedHooks.chatNode('missing'))
expect(flow.injected.keyedHooks.chatNodeProcess('missing')).toBe(injected.keyedHooks.chatNodeProcess('missing'))
expect(flow.injected.keyedHooks.chatNodeBottom('missing')).toBe(injected.keyedHooks.chatNodeBottom('missing'))
expect(flow.injected.hooks.connectionState).toBe(b.runtime.ctx.get('connection')!.state)
expect(flow.injected.hooks.presentation).toBe(injected.hooks.presentation)
expect(injected.fileMentions(owner)).toBeUndefined()
const mentions = { resolve: vi.fn() } as never
@@ -51,6 +51,7 @@ async function bench() {
? chatSettings.scope
: stubConfigForm().scope,
} as never)
runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never)
runtime.ctx.provide('sidebarRightTabs', {
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff. Show more