mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge origin/master: fold admitEncodedImages onto AttachmentStore.saveImages
master introduced AttachmentStore.saveImages as the batch admission (count/aggregate-byte/media-type limits, validate-all-before-save, ordered commit). admitEncodedImages narrows to the shared wire entry: canonical-base64 enforcement plus delegation to saveImages, keeping one home for batch policy while both wire endpoints (prompt RPC and the command executor) still call one function. Test doubles gain saveImages; batch-limit error texts follow saveImages' wording.
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md
|
||||
2026-07-31-code-runtime-python-fd3-protocol.md: 5f9600a3f658df907d68ae695d42154009947fbd
|
||||
2026-07-31-code-runtime-python-fd3-protocol.zh.md: dc3ae7cdfe1daf6e2ab1326e353c1bdbf9833175
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: the code-runtime-python fd-3 frame protocol
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-code-runtime-python-fd3-protocol.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The CPython code-runtime backend (`@deepseek-ai/dsh-code-runtime-python`, arriving across a PR stack) runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. That channel needs a wire protocol both sides agree on, and the host cannot trust it: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify`/`json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded.
|
||||
|
||||
This layer of the stack delivers only that protocol, so the large `PythonCodeRuntime` implementation and its real-subprocess integration suite land on a reviewed wire contract instead of arriving fused with it. The parent stack splits [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436) — a 9000-line single PR — into reviewable layers; this is the protocol layer, based on the [seam extension](2026-07-31-code-runtime-portable-identifier-seam.md).
|
||||
|
||||
## Decision
|
||||
|
||||
`src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec:
|
||||
|
||||
- **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler.
|
||||
- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the enqueued children; strings and keys are metered by a non-allocating escaped-size scan (`jsonStringBytesUpTo`), so the escaped copy is never materialized. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form.
|
||||
- **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget.
|
||||
|
||||
`py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text.
|
||||
|
||||
The package skeleton (`package.json`, `tsconfig.json`, `tsdown.config.ts`, `src/index.ts`, `src/invariant.ts`, README triplet) ships here rather than in a later stack layer: `check-workspace-constraints` reads every `packages/<group>/<pkg>` package.json unconditionally, and the coverage and invariant-topology gates require the package to exist and build the moment its directory does. The later backend-core PR extends `src/index.ts` with `PythonCodeRuntime` and grows `package.json`'s dependencies; because it bases on this branch, those are edits, not conflicts.
|
||||
|
||||
## Wire contract
|
||||
|
||||
Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames.
|
||||
|
||||
## Mirror alignment
|
||||
|
||||
Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. To keep it aligned, `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`: `PROTOCOL_FD` and `log_truncation_marker` (the two surfaces both sides execute), and each `TypedDict`'s required/optional wire field set — so a renamed or dropped field, or one side making a field optional the other requires (exactly the round-12 drift), fails the test. Field *types* are not compared across the language boundary; that residue stays with review.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Move the Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) into `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates HOSTILE input and is self-contained. The Python codec produces output on the TRUSTED side and is coupled to bootstrap-internal helpers (`_Emit`, `_dump_scalar`/`_dump_string`/`_dump_float`, `LogBuffer`'s cost accounting, `_check_done_value`, `_lossless_json_violation`); lifting only the two entry points would drag that web into `protocol.py` or create a `bootstrap.py` ↔ `protocol.py` import cycle. The real cross-side parallel is "host validates inbound (`protocol.ts`) ↔ child trusts host and emits (`bootstrap.py`)", and that symmetry is preserved: `protocol.py` stays the pure wire-vocabulary mirror it is on the TS side. The Python codec stays in `bootstrap.py`, delivered by the backend-core PR.
|
||||
|
||||
**Defer the package skeleton to the backend-core PR that "owns" package.json.** Rejected: the workspace-constraint, coverage, and invariant-topology gates fail the instant the `code-runtime-python` directory exists without a buildable package. A stacked split cannot create source files in a package that does not yet compile.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract.
|
||||
|
||||
Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The mirror e2e compares field NAMES and required/optional-ness across the two sides but not field TYPES — comparing type declarations across TypeScript and Python has no mechanical equivalent, so that residue stays with review plus the backend's real-subprocess suite.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: the code-runtime-python fd-3 frame protocol
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-code-runtime-python-fd3-protocol.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。
|
||||
|
||||
本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。
|
||||
|
||||
## Decision
|
||||
|
||||
`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码:
|
||||
|
||||
- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。
|
||||
- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——即入栈子节点;字符串与 key 由非分配的转义尺寸扫描(`jsonStringBytesUpTo`)计量,从不物化转义副本。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。
|
||||
- **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。
|
||||
|
||||
`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。
|
||||
|
||||
包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages/<group>/<pkg>` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。
|
||||
|
||||
## Wire contract
|
||||
|
||||
帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
|
||||
|
||||
## Mirror alignment
|
||||
|
||||
#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。为持续保持对齐,`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言:`PROTOCOL_FD` 与 `log_truncation_marker`(两侧都会执行的面),以及每个 `TypedDict` 的必填/可选 wire 字段集——于是字段被重命名或删除、或一侧把另一侧要求的字段改成可选(正是 round-12 那类漂移),测试即失败。字段的*类型*不跨语言边界比较,那部分残留留给 review。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 “host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)”,这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。
|
||||
|
||||
**把包骨架推迟到“拥有” package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。
|
||||
|
||||
## Consequences
|
||||
|
||||
收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。
|
||||
|
||||
代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。mirror e2e 比较两侧的字段名与必填/可选性,但不比较字段类型——跨 TypeScript 与 Python 比较类型声明无机械等价物,那部分残留留给 review 加后端真子进程套件。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
|
||||
2026-07-07-mcp-client-plugin.md: 84fbda6f7832d82854144d8e3687bfe23fec6fa4
|
||||
2026-07-07-mcp-client-plugin.zh.md: 3270803f7cb2cea08e170fdeb84c253c97e16a8e
|
||||
2026-07-07-mcp-client-plugin.md: f9d997fd06dbf14f86ec344a2d5f16c7412119d0
|
||||
2026-07-07-mcp-client-plugin.zh.md: a4b89a948c14d564d2fc37bee6f91fb923e9ed49
|
||||
|
||||
@@ -141,11 +141,11 @@ Tools are never silently skipped; which tools are available never depends on plu
|
||||
A unified `execute` handler for all tools from one MCP server:
|
||||
|
||||
1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server.
|
||||
2. Map the result:
|
||||
- Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries).
|
||||
- `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)).
|
||||
- `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`).
|
||||
3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server.
|
||||
2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/Code Mode value. `isError: true` throws before any image persistence so the registry owns the failure path.
|
||||
3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio, embedded resources, malformed blocks, and unknown types become explicit diagnostics. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references.
|
||||
4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state.
|
||||
5. Code Mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case.
|
||||
6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate.
|
||||
|
||||
### Subprocess environment (stdio transport)
|
||||
|
||||
@@ -189,13 +189,25 @@ Rejected. The remote name is untrusted, non-unique across deployments, and chang
|
||||
|
||||
Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit.
|
||||
|
||||
### Replace the canonical MCP result with core `ContentBlock[]`
|
||||
|
||||
Rejected. Programmatic callers need protocol-complete MCP blocks and `structuredContent`, while Native consumers need durable core images rather than base64. One canonical protocol value plus a separate projection preserves both contracts.
|
||||
|
||||
### Add a generic RichContent service or perform I/O in `output.render`
|
||||
|
||||
Rejected. Core already owns the role-neutral content vocabulary, and a second service would duplicate its logging and ordering contracts. `output.render` is pure, synchronous, and replayable, so attachment I/O belongs in async execution with an exact finalization handoff.
|
||||
|
||||
### Let each image-returning tool special-case Code Mode parents
|
||||
|
||||
Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic Code Mode bridge observes the final post-policy content and forwards image-bearing results uniformly.
|
||||
|
||||
## Testing
|
||||
|
||||
Coverage is named per tier; each behavior lives at the cheapest tier that can express it.
|
||||
|
||||
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package.
|
||||
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal.
|
||||
- **Snapshot**: deliberately none. MCP tools introduce no new presentation shape — they register as raw `ToolDefinition`s and UI consumers use the generic-card fallback already pinned by their presentation suites. Adding an MCP server to a runnable snapshot composition would mutate its pinned system-prompt fixture and make every replay depend on spawning an external MCP server process for no new behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
|
||||
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, lossless canonical results, mixed rich ordering, atomic malformed batches, exact capability/store refusal, explicit non-image diagnostics, post-execute policy precedence, cancellation, and config schema validation. 100% per-file coverage gates the package.
|
||||
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, durable image save/read with base64 retained only in the canonical value, explicit refusal without an image route, duplicate-`serverName` rejection, and disposal.
|
||||
- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the Code Mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -206,3 +218,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex
|
||||
- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's.
|
||||
- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
|
||||
- Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`.
|
||||
- Image payloads can enter model context only through the shared durable attachment store and an exact positive route capability. Audio and embedded-resource payloads remain execution-local with explicit diagnostics.
|
||||
|
||||
@@ -141,11 +141,11 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
|
||||
为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器:
|
||||
|
||||
1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。
|
||||
2. 映射结果:
|
||||
- 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(之所以必须这样做,是因为 `flattenText` 使用无分隔符的 `join('')`,多个内容块会丢失块间边界)。
|
||||
- `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[删除图片 Agent Note](../simplification/2026-07-04-drop-image-content-block.md))。
|
||||
- `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。
|
||||
3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。
|
||||
2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/Code Mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
|
||||
3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
|
||||
4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。
|
||||
5. Code Mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。
|
||||
6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。
|
||||
|
||||
### 子进程环境(stdio 传输)
|
||||
|
||||
@@ -189,13 +189,25 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
|
||||
|
||||
否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlock;MCP 桥接遵循同一做法。
|
||||
|
||||
### 用核心 `ContentBlock[]` 替换规范 MCP 结果
|
||||
|
||||
不予采用。程序化调用方需要协议完整的 MCP 块和 `structuredContent`,Native 消费方则需要持久核心图片而不是 base64。一份规范协议值加一份独立投影可以同时保留两项契约。
|
||||
|
||||
### 添加通用 RichContent 服务,或在 `output.render` 中执行 I/O
|
||||
|
||||
不予采用。核心已经拥有角色无关的内容词汇,第二套服务会重复其日志与顺序契约。`output.render` 必须纯净、同步且可回放,因此附件 I/O 属于异步执行,再经确切的最终化交接安装结果。
|
||||
|
||||
### 让每个返回图片的工具分别特殊处理 Code Mode 父调用
|
||||
|
||||
不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 Code Mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。
|
||||
|
||||
## 测试
|
||||
|
||||
覆盖范围按层级列出;每项行为都放在能够表达它的最低成本层级。
|
||||
|
||||
- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
|
||||
- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。
|
||||
- **快照**:刻意不做。MCP 工具不引入新的展示形态——它们以原始 `ToolDefinition` 注册,UI 消费方使用各自展示测试套件已固定的通用卡片兜底。将 MCP 服务器添加到某个可运行的快照组合会改变其已固定的系统提示词 fixture,且使每次回放依赖于 spawn 外部 MCP 服务器进程,而新增行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。
|
||||
- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、无损规范结果、丰富内容混合顺序、格式错误批次原子性、确切能力/存储拒绝、明确的非图片诊断、post-execute 策略优先级、取消,以及配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
|
||||
- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、持久图片保存/读取且 base64 只保留在规范值中、缺少图片路由时明确拒绝、重复 `serverName` 拒绝,以及 dispose。
|
||||
- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 Code Mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -206,3 +218,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
|
||||
- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
|
||||
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。
|
||||
- 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。
|
||||
- 图片载荷只有通过共享持久附件存储和确切正向路由能力,才能进入模型上下文。音频与嵌入资源载荷仍只存在于执行局部,并附带明确诊断。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md
|
||||
2026-07-20-code-mode-typed-tool-returns.md: 747b33384238957190932e07a5f41d01cf1b65b6
|
||||
2026-07-20-code-mode-typed-tool-returns.zh.md: 9ad41285ca76427c39a63ae54f80fe34e62f2520
|
||||
2026-07-20-code-mode-typed-tool-returns.md: a8a251f5f0d39f4deedc42e08eb45c2b5fa11807
|
||||
2026-07-20-code-mode-typed-tool-returns.zh.md: 3d7ae4f98c569d3908f14fc918aebe7190e7ce61
|
||||
|
||||
@@ -14,7 +14,7 @@ The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-o
|
||||
|
||||
## Decision
|
||||
|
||||
Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. Only the outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and the model-facing spill pipeline.
|
||||
Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context.
|
||||
|
||||
This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note.
|
||||
|
||||
@@ -49,7 +49,7 @@ declare const tools: {
|
||||
|
||||
### Binding values and failures
|
||||
|
||||
Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
|
||||
Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. Image-bearing final content is not a second binding value: the bridge ferries it after the outer result so the next model request can see the durable image, while post-execute block/content replacement remains authoritative and text-only results are not duplicated.
|
||||
|
||||
Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.
|
||||
|
||||
@@ -73,13 +73,13 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu
|
||||
|
||||
### Persistence, metadata, and spill
|
||||
|
||||
Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values.
|
||||
Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values.
|
||||
|
||||
The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`.
|
||||
|
||||
## Testing
|
||||
|
||||
Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution.
|
||||
Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled Code Mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution.
|
||||
|
||||
Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its job id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `job_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text.
|
||||
|
||||
@@ -93,6 +93,10 @@ Keyless real-worker integration tests pin the two handle workflows that prose re
|
||||
|
||||
**Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill.
|
||||
|
||||
**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to Code Mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result.
|
||||
|
||||
**Expose Native rich content as part of every binding's canonical value.** Rejected because a canonical value is lossless JSON and tool-specific; attachment blocks are a model projection with durable lifecycle semantics. Keeping the value and projection separate preserves typed programs without dropping images from later model context.
|
||||
|
||||
## Consequences
|
||||
|
||||
Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer.
|
||||
@@ -107,6 +111,6 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu
|
||||
- Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost.
|
||||
- The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap.
|
||||
- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode.
|
||||
- Unsupported MCP output schemas fall back to `JsonValue`; richer Native multimedia projection is deferred.
|
||||
- Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only.
|
||||
- There is one result card per outer `run_code`, never per nested call.
|
||||
- Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union.
|
||||
|
||||
@@ -14,7 +14,7 @@ Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投
|
||||
|
||||
## 决策
|
||||
|
||||
Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的 spill 流水线。
|
||||
Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。
|
||||
|
||||
本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。
|
||||
|
||||
@@ -49,7 +49,7 @@ declare const tools: {
|
||||
|
||||
### 绑定值与失败
|
||||
|
||||
分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。
|
||||
分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。含图片的最终内容不是第二份绑定值:桥接层会在外层结果之后转运它,使下一次模型请求可以看到持久图片;post-execute 阻止/内容替换仍具有权威性,纯文本结果不会重复。
|
||||
|
||||
Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。
|
||||
|
||||
@@ -73,13 +73,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper
|
||||
|
||||
### 持久化、元数据与 spill
|
||||
|
||||
嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。
|
||||
嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。
|
||||
|
||||
不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的 spill 投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。
|
||||
|
||||
## 测试
|
||||
|
||||
编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套 spill 抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的 spill;不可信对端伪造的流量;以及构建后包的执行。
|
||||
编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 Code Mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。
|
||||
|
||||
无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 job id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且由 `job_kill` 负责取消。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。
|
||||
|
||||
@@ -93,6 +93,10 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper
|
||||
|
||||
**静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层 spill 机制。
|
||||
|
||||
**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 Code Mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。
|
||||
|
||||
**把 Native 丰富内容暴露为每个绑定规范值的一部分。** 不予采用,因为规范值是无损 JSON 且由工具定义;附件块是具有持久生命周期语义的模型投影。保持值与投影分离,既能保留类型化程序,也不会从后续模型上下文中丢弃图片。
|
||||
|
||||
## 后果
|
||||
|
||||
Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。
|
||||
@@ -107,6 +111,6 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损
|
||||
- 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。
|
||||
- 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;spill 无法恢复超出该上限后被拒绝的字节。
|
||||
- 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。
|
||||
- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;更丰富的 Native 多媒体投影留待后续实现。
|
||||
- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。
|
||||
- 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。
|
||||
- Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: fcbe301f705f8e01095eac9a02329904626af843
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 338f27c6db934092b62ed6107f8706fe451b6a4b
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 5162e0d6f63715ffd83cff1a622e07c110a7938e
|
||||
2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d3a5cf18a7d308753d5e46741cea2b2993536f70
|
||||
|
||||
+22
-7
@@ -16,7 +16,7 @@ Peer products converge on an attachment rail above the editor, but their storage
|
||||
|
||||
## Decision
|
||||
|
||||
Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references.
|
||||
Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. Every rich-content intake adapter decodes its wire blocks, proves route capability, and delegates the complete image batch to the attachment service before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references.
|
||||
|
||||
Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on a single click (display and interaction specifics superseded in part by the [attachment-display alignment note](2026-08-11-web-attachment-display-alignment.md)). File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups.
|
||||
|
||||
@@ -114,7 +114,7 @@ type PromptInputPart =
|
||||
}
|
||||
```
|
||||
|
||||
Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure exposes no attachment path or raw bytes.
|
||||
Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME shape, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, and decoded-pixel count; it validates every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes.
|
||||
|
||||
`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache.
|
||||
|
||||
@@ -128,7 +128,7 @@ The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments`
|
||||
|
||||
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically.
|
||||
|
||||
Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol API gains native image support rather than silently omitting the block.
|
||||
Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context.
|
||||
|
||||
Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`.
|
||||
|
||||
@@ -148,22 +148,24 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc
|
||||
|
||||
| Surface | Responsibility |
|
||||
| --- | --- |
|
||||
| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. |
|
||||
| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and single/batch admission through `ctx.attachments`. |
|
||||
| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. |
|
||||
| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. |
|
||||
| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. |
|
||||
| `packages/llm/llm-deepseek` | Reject image content explicitly. |
|
||||
| `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. |
|
||||
| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits and model preflight, plus default profile composition. |
|
||||
| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. |
|
||||
| `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
|
||||
| `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. |
|
||||
| `packages/acp/acp` | Explicit fallback rendering for image blocks. |
|
||||
| `packages/acp/acp` | Conditional native image capability, atomic inline-image admission, and verified assistant-image delivery. |
|
||||
| `packages/mcp/mcp-client` | Lossless canonical MCP results plus capability-gated durable image projection and explicit diagnostics for unsupported rich blocks. |
|
||||
| `packages/core/tools` | Generic Code Mode forwarding of settled image-bearing sub-results after the outer result. |
|
||||
|
||||
The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`.
|
||||
|
||||
### Implementation
|
||||
|
||||
The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web coverage.
|
||||
The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage.
|
||||
|
||||
No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
|
||||
|
||||
@@ -193,12 +195,25 @@ Composer presentation can use a generic attachment rail, but provider semantics
|
||||
|
||||
UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback.
|
||||
|
||||
### Add a generic RichContent service above the core content vocabulary
|
||||
|
||||
Rejected because the core already has the role-neutral `ContentBlock` vocabulary and attachment references. A second generic service would duplicate ordering, capability, logging, and lifetime semantics while still requiring each wire adapter to parse its own protocol. Narrow image adapters around the existing core preserve ownership and leave audio/resources to earn their own lifecycle contracts.
|
||||
|
||||
### Normalize MCP results into core content as the canonical tool value
|
||||
|
||||
Rejected because Code Mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative.
|
||||
|
||||
### Perform attachment reads and writes inside synchronous output renderers
|
||||
|
||||
Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. Code Mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior.
|
||||
|
||||
## Testing
|
||||
|
||||
- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered.
|
||||
- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction.
|
||||
- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail.
|
||||
- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection.
|
||||
- Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log.
|
||||
- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code.
|
||||
- The current production adapter set has no certified image-output route; output-provider certification remains outside version one.
|
||||
|
||||
|
||||
+22
-7
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。
|
||||
粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。每个丰富内容接入适配器都会解码自身协议块、证明路由能力,并在追加消息事件前把完整图片批次委托给附件服务。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。
|
||||
|
||||
第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持单击预览原图(展示与交互细节部分由[附件展示对齐 Note](2026-08-11-web-attachment-display-alignment.md)取代)。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。
|
||||
|
||||
@@ -114,7 +114,7 @@ type PromptInputPart =
|
||||
}
|
||||
```
|
||||
|
||||
Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待seam 上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。
|
||||
Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 形状,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数;它会在保存任何成员之前校验每个批次成员,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。
|
||||
|
||||
`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。
|
||||
|
||||
@@ -128,7 +128,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
|
||||
|
||||
核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。
|
||||
|
||||
提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。
|
||||
提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。
|
||||
|
||||
压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。
|
||||
|
||||
@@ -148,22 +148,24 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
|
||||
|
||||
| 接口 | 职责 |
|
||||
| --- | --- |
|
||||
| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 |
|
||||
| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误,以及通过 `ctx.attachments` 提供的单张/批量准入。 |
|
||||
| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
|
||||
| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
|
||||
| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
|
||||
| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
|
||||
| `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 |
|
||||
| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查,以及默认 profile 组合。 |
|
||||
| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 |
|
||||
| `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 |
|
||||
| `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 |
|
||||
| `packages/acp/acp` | 图片块的明确兜底渲染。 |
|
||||
| `packages/acp/acp` | 条件式原生图片能力、原子内联图片准入,以及经过校验的助手图片交付。 |
|
||||
| `packages/mcp/mcp-client` | 无损规范 MCP 结果、经能力门禁的持久图片投影,以及针对不受支持丰富块的明确诊断。 |
|
||||
| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 Code Mode 子结果。 |
|
||||
|
||||
附件包构成一个能力 seam 的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。
|
||||
|
||||
### 实现
|
||||
|
||||
已实现的范围包括附件 seam、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。
|
||||
已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。
|
||||
|
||||
预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。
|
||||
|
||||
@@ -193,12 +195,25 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme
|
||||
|
||||
UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。
|
||||
|
||||
### 在核心内容词汇之上添加通用 RichContent 服务
|
||||
|
||||
不予采用,因为核心已经拥有角色无关的 `ContentBlock` 词汇与附件引用。第二套通用服务会重复顺序、能力、日志和生命周期语义,同时每个协议适配器仍需解析自身协议。围绕现有核心构建范围狭窄的图片适配器,可以保持归属清晰,并让音频/资源在确有需要时建立自己的生命周期契约。
|
||||
|
||||
### 把 MCP 结果规范化为核心内容,并将其作为规范工具值
|
||||
|
||||
不予采用,因为 Code Mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。
|
||||
|
||||
### 在同步输出渲染器中执行附件读写
|
||||
|
||||
不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。Code Mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。
|
||||
|
||||
## 测试
|
||||
|
||||
- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。
|
||||
- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。
|
||||
- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。
|
||||
- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。
|
||||
- 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。
|
||||
- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。
|
||||
- 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md
|
||||
2026-08-17-command-image-attachment-envelope.md: 64897b25f989210d5fa73f3a4f8124f6cbac73fb
|
||||
2026-08-17-command-image-attachment-envelope.zh.md: d2741e294ac1e30fb6e0bd3ffd0112799e39c17e
|
||||
2026-08-17-command-image-attachment-envelope.md: d811a37284b944949482a94842287122219d2314
|
||||
2026-08-17-command-image-attachment-envelope.zh.md: 38d5cecfbb59949e8c9f86f937ed7a7669dcdb6c
|
||||
|
||||
@@ -16,7 +16,7 @@ The submission envelope is modeled end to end, and every command route either co
|
||||
|
||||
**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot.
|
||||
|
||||
**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission reuses the attachment package's `admitEncodedImages`, extracted from api-proxy's prompt path so both wire endpoints share one limits/validation/commit sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
|
||||
**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`.
|
||||
|
||||
**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan` folds the images into the message it already steers. Both producers reject sub-commands whose grammar has no carrier (`/goal pause`, bare `/plan`, `/plan off`) with a direct error, which keeps the composer's images in place.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Web composer 的一次提交是一个信封——草稿文本、已附加图片
|
||||
|
||||
**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。
|
||||
|
||||
**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入复用 attachment 包的 `admitEncodedImages`——从 api-proxy 的 prompt 路径提取而来,使两个 wire 端点共享同一套限额、校验与提交序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
|
||||
**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。
|
||||
|
||||
**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan` 把图片并入它本就要 steer 的消息。两个生产方都会拒绝语法上没有载体的子命令(`/goal pause`、不带参数的 `/plan`、`/plan off`),直接返回错误,composer 的图片原地保留。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
|
||||
2026-08-08-native-windows-pull-request-ci.md: d7db73b2049ae2d08f8996bcfd5b54fa15901478
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: 474b7f71aca8fbb5e0082fd2e4faf8e462bf0c0f
|
||||
2026-08-08-native-windows-pull-request-ci.md: 31a1a1893b0c6248a30ac6e12b409282f608a689
|
||||
2026-08-08-native-windows-pull-request-ci.zh.md: ba2520c2514580e5af367df86dd3195485a0a34c
|
||||
|
||||
@@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name
|
||||
|
||||
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage.
|
||||
|
||||
The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 60-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform.
|
||||
|
||||
The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。
|
||||
|
||||
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入免覆盖率项较多的门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。
|
||||
|
||||
16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md
|
||||
2026-07-23-acp-automation-only-protocol.md: e7a6670a44db9d4fd2bba5f013083e35d60fc313
|
||||
2026-07-23-acp-automation-only-protocol.zh.md: cdefe80c3e868a41541c7ebea42e3811c7fb9249
|
||||
2026-07-23-acp-automation-only-protocol.md: deeba55ebb48468af80a6c74a704b18e07f33477
|
||||
2026-07-23-acp-automation-only-protocol.zh.md: ab23280ca7d2f39b33b80f1d9fefe977345b8676
|
||||
|
||||
+14
-6
@@ -8,15 +8,17 @@ English | [中文](2026-07-23-acp-automation-only-protocol.zh.md)
|
||||
|
||||
The ACP bridge had become a second interactive product UI. It translated durable events into editor cards, terminal metadata, diffs, plans, titles, reasoning, commands, modes, model and permission pickers, session navigation, and human elicitation. Those responsibilities duplicated the TUI and the Web client while coupling an automation transport to UI services, persistence queries, presentation policy, and editor-specific conventions.
|
||||
|
||||
ACP still has one useful role: another agent or automated controller can start a harness process, create an isolated session, send text, receive the committed answer, cancel work, and answer a permission request. The out-of-process ACP subagent backend depends on that standard protocol boundary.
|
||||
ACP still has one useful role: another agent or automated controller can start a harness process, create an isolated session, send text or a narrowly supported inline image, receive the committed text/image answer, cancel work, and answer a permission request. The out-of-process ACP subagent backend depends on that standard protocol boundary.
|
||||
|
||||
The snapshot suite complicates removal. Most ACP scenarios exercise the assembled agent backend rather than ACP presentation, so deleting the suite with the editor bridge would discard broad keyless behavioral coverage.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh text sessions with one in-flight prompt each, committed assistant text updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts carry the spec-required baseline only — text plus resource links flattened to bracketed textual references; the bridge rejects additional directories, MCP servers, beyond-baseline prompt content (image, audio, embedded resources), empty prompts, unknown sessions, and overlapping prompts.
|
||||
`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh sessions with one in-flight prompt each, committed assistant text/image updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge rejects additional directories, MCP servers, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts.
|
||||
|
||||
The bridge emits only committed `assistant/message` text. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation.
|
||||
Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults.
|
||||
|
||||
The bridge emits only committed `assistant/message` text and images. A per-session promise chain preserves block and message order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation.
|
||||
|
||||
One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically.
|
||||
|
||||
@@ -24,13 +26,13 @@ The app composition contains the agent spine, persistence, checkpoint policy, an
|
||||
|
||||
The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output.
|
||||
|
||||
Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle.
|
||||
Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure cancel prompt admission and agents, drain ordered output, settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle.
|
||||
|
||||
## Snapshot boundary
|
||||
|
||||
The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions.
|
||||
|
||||
Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiation, fresh-session creation, text and resource-link flattening, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement, per-session cancellation, failed transport closure, ACP-only reload cleanup, and teardown quiescence. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant.
|
||||
Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup or cancellation of unrelated Agent work, exclusion of unrelated pre-inbox failures, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -44,10 +46,16 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat
|
||||
|
||||
**Delete the ACP snapshot suite or migrate every scenario in this change.** Rejected because most scenarios test the backend and remain valuable, while a full harness migration is an independent testing change. Only scenarios whose driver was a deleted UI method leave this suite.
|
||||
|
||||
**Advertise image support whenever the ACP SDK has an image block.** Rejected because protocol vocabulary does not prove this deployment can persist bytes or that the configured exact route accepts visual input. Unknown capability is false at initialization; prompt admission rechecks the live route.
|
||||
|
||||
**Flatten inline and assistant images to markers or persist ACP base64 in session events.** Rejected because markers silently lose model/user intent and base64 makes durable logs the binary store. ACP translates between its wire block and the existing durable `ImageBlock` reference at the transport boundary.
|
||||
|
||||
**Create a generic RichContent service for ACP, MCP, and Web.** Rejected because core `ContentBlock` plus the attachment seam already own the shared contract. Each front door keeps only protocol parsing, capability proof, and lifecycle orchestration; shared batch limits and image validation stay in `AttachmentStore.saveImages()`.
|
||||
|
||||
## Consequences
|
||||
|
||||
ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point.
|
||||
|
||||
Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP.
|
||||
Automation clients receive complete committed text/images rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP.
|
||||
|
||||
Backend snapshot coverage therefore remains transport-coupled to ACP even though that transport is incidental to the behavior under test.
|
||||
|
||||
+14
-6
@@ -8,15 +8,17 @@ Status: implemented
|
||||
|
||||
ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理(reasoning)、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。
|
||||
|
||||
ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控制器可以启动 harness 进程、创建隔离会话、发送文本、接收已提交的回答、取消工作并回答权限请求。跨进程 ACP subagent 后端依赖这个标准协议边界。
|
||||
ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控制器可以启动 harness 进程、创建隔离会话、发送文本或范围狭窄的受支持内联图片、接收已提交的文本/图片回答、取消工作并回答权限请求。跨进程 ACP subagent 后端依赖这个标准协议边界。
|
||||
|
||||
快照套件使移除工作更复杂。大多数 ACP 场景测试的是组装后的 agent 后端,而不是 ACP 展示层;如果随编辑器桥接层一起删除整个套件,就会丢失大量无密钥行为测试。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新文本会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词只承载规范要求的基线内容——文本,加上被展平为方括号文本引用的资源链接;桥接层会拒绝附加目录、MCP 服务器、超出基线的提示词内容(图片、音频、内嵌资源)、空提示词、未知会话和重叠提示词。
|
||||
`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本/图片更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层会拒绝附加目录、MCP 服务器、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。
|
||||
|
||||
桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。
|
||||
图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。
|
||||
|
||||
桥接层只发出已提交的 `assistant/message` 文本与图片。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时保持块与消息顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。
|
||||
|
||||
保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中登记的同一 agent 对象;不属于桥接层当前 agent 的请求或未关联具体调用的请求会继续委派;RPC 失败则映射为故障时默认拒绝的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。
|
||||
|
||||
@@ -24,13 +26,13 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控
|
||||
|
||||
传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop(智能体循环)。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。
|
||||
|
||||
断开连接与插件 dispose(资源释放)共享同一个经记忆化的完全停稳边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待 agent loop 和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。
|
||||
断开连接与插件 dispose(资源释放)共享同一个经记忆化的完全停稳边界。传输关闭无论成功还是失败,都会取消提示词准入和 agent、排空有序输出、将待处理提示词以已取消状态结算、dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。
|
||||
|
||||
## 快照边界
|
||||
|
||||
ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。
|
||||
|
||||
协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。
|
||||
协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup 或取消无关 Agent 工作、排除进入 inbox 前的无关失败、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -44,10 +46,16 @@ ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后
|
||||
|
||||
**删除 ACP 快照套件,或在本次变更中迁移每个场景。** 不予采用,因为大多数场景测试后端且仍有价值,而完整的 harness 迁移是一项独立的测试变更。只有通过已删除的 UI 方法驱动的场景才离开该套件。
|
||||
|
||||
**只要 ACP SDK 具有图片块就公布图片支持。** 不予采用,因为协议词汇不能证明当前部署可以持久化字节,也不能证明配置的确切路由接受视觉输入。初始化时能力未知即为 false;提示词准入会重新检查实时路由。
|
||||
|
||||
**把内联图片和助手图片展平为标记,或把 ACP base64 持久化进会话事件。** 不予采用,因为标记会静默丢失模型/用户意图,base64 则会让持久日志变成二进制存储。ACP 在传输边界把自身协议块与现有持久 `ImageBlock` 引用相互转换。
|
||||
|
||||
**为 ACP、MCP 和 Web 创建通用 RichContent 服务。** 不予采用,因为核心 `ContentBlock` 与附件 seam 已经拥有共享契约。每个入口只保留协议解析、能力证明与生命周期编排;共享批次限制和图片校验留在 `AttachmentStore.saveImages()` 中。
|
||||
|
||||
## 结果
|
||||
|
||||
ACP 具有适合 agent 与自动化的精简约定,而 TUI 和 Web 拥有面向人类的交互与展示。该包注入的服务、依赖、协议分支和生命周期状态更少,也不再将自身定位为通用编辑器入口。
|
||||
|
||||
自动化客户端收到完整的已提交文本,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。
|
||||
自动化客户端收到完整的已提交文本/图片,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。
|
||||
|
||||
因此,后端快照测试仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md
|
||||
2026-08-12-required-python-runtime-pull-request-ci.md: 2f5dcac17262bd885049221620a4d9708b083faa
|
||||
2026-08-12-required-python-runtime-pull-request-ci.zh.md: 66c81f70d1bf25355425030883ccc4307efbb1ce
|
||||
2026-08-12-required-python-runtime-pull-request-ci.md: 61b1e832be6d29eafe5cb304d2bca3f0a59e3d84
|
||||
2026-08-12-required-python-runtime-pull-request-ci.zh.md: 92bf80688d8d152b26fdd893fe0f0b96553868e9
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Ordinary pull-request CI runs the complete Python SDK pytest suite against fake
|
||||
|
||||
## Decision
|
||||
|
||||
Every pull request has a required `python-runtime` job in [CI](../../../../.github/workflows/ci.yml). It calls the shared [single-executable builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) for `node24-linux-x64` without a path filter and participates in `all checks passed`. The called workflow builds the real executable, runs all keyless Python full-turn and direct-binary scenarios including the committed executable snapshot, builds the SDK and runtime wheels, installs them into a clean virtual environment, checks the executable and native addon's GLIBC requirements, and runs the installed wheels in a manylinux 2.28 container.
|
||||
Every pull request has a required `python-runtime` job in [CI](../../../../.github/workflows/ci.yml). It calls the shared [single-executable builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) for `node24-linux-x64` without a path filter and participates in `all checks passed`. The called workflow builds the real executable, runs all keyless Python full-turn and direct-binary scenarios including both committed snapshots, builds the SDK and runtime wheels, installs them into a clean virtual environment, checks the executable and native addon's GLIBC requirements, and runs the installed wheels in a manylinux 2.28 container.
|
||||
|
||||
The required job and the [Python publication workflow](../process/2026-08-11-python-publication-workflow.md) use the same builder. Its concurrency key includes the caller workflow, so required CI and an explicit full release validation for the same ref do not cancel each other. The complete linux-x64, linux-arm64, and macos-arm64 matrix remains a release validation because platform-independent runtime, SDK, and snapshot behavior needs one merge-blocking native carrier, while architecture-specific executable, addon, wheel-tag, and deployment-target behavior still needs all release targets before publication.
|
||||
|
||||
The executable snapshot normalizes opaque session, message, subagent, and workflow-run identifiers before comparison. A newly persisted workflow event therefore changes the reviewed expected output without making a random run identifier part of that output.
|
||||
The advanced executable snapshot normalizes opaque session, message, subagent, and workflow-run identifiers before comparison. A newly persisted workflow event therefore changes the reviewed expected output without making a random run identifier part of that output. The minimal scenario's [model-visible snapshot](2026-08-13-python-minimal-model-visible-snapshot.md) covers the assembled system prompt, tool schemas, and message list that this one tokenizes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括检入的 exe 快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。
|
||||
每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括两份检入的快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。
|
||||
|
||||
必需作业与 [Python 发布工作流](../process/2026-08-11-python-publication-workflow.md)共用同一构建器。其并发键包含调用方工作流,因此同一 ref 上的必需 CI 与显式完整发布验证不会互相取消。完整的 linux-x64、linux-arm64 和 macos-arm64 矩阵仍属于发布验证:平台无关的运行时、SDK 与快照行为只需要一个阻断合并的原生载体,而架构相关的可执行文件、addon、wheel 包标签与部署目标行为在发布前仍需要全部发布目标验证。
|
||||
|
||||
exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。
|
||||
进阶 exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。极简场景的[模型可见快照](2026-08-13-python-minimal-model-visible-snapshot.md)覆盖了这份快照所占位化的已组装系统提示词、工具 schema 与消息列表。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md
|
||||
2026-08-13-python-minimal-model-visible-snapshot.md: 66cfa1ed667d9a60579b0d27ddca2667614d7e1c
|
||||
2026-08-13-python-minimal-model-visible-snapshot.zh.md: 40f596208f07532e68e382013b36e0d7ec46de3d
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Python minimal-composition model-visible snapshot
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-13-python-minimal-model-visible-snapshot.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/jsonrpc-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same.
|
||||
|
||||
## Decision
|
||||
|
||||
The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across the platforms the expected output replays on.
|
||||
|
||||
One model-visible message is excluded: the agent loop's dynamic runtime-context snapshot. The same composition emits it on macOS and not on Linux, which the required lane runs, so no single expected output can carry it. That difference is a defect in its own right ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)) — this expected output covers every other model-visible message rather than waiting for it.
|
||||
|
||||
The mock model no longer asserts the minimal scenario's tools and system prompts — the snapshot owns that surface and reports a complete diff instead of the first mismatch. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Snapshot the minimal session log, like the advanced scenario.** The minimal turn drives a real PTY and editor, so persisted tool results carry platform-dependent text. The expected output would go red for reasons unrelated to model-visible assembly, and normalizing that text away leaves the log carrying little the model-visible file does not.
|
||||
|
||||
**Extend the mock model's inline assertions.** Every new model-visible contribution would need another hand-written expectation, and a failure names one mismatch rather than the whole surface. Tool descriptions would also be duplicated from the composition into the script.
|
||||
|
||||
**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins the same composition's system prompt, tool schemas, and runtime context, but through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's closure assembles for a Python caller.
|
||||
|
||||
## Consequences
|
||||
|
||||
A change to the minimal composition's model-visible surface — a system section, a tool, a tool description, or an added user message — now fails `python-runtime` with the exact diff, and landing it means rerunning `--scenario sdk-minimal --update-snapshots` and reviewing that diff. The minimal composition's tool descriptions become reviewed expected output.
|
||||
|
||||
Assistant and tool message text is no longer compared, and the runtime-context snapshot is not compared at all. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) owns the excluded message until its platform difference is resolved.
|
||||
|
||||
[AGENTS.md](../../../../AGENTS.md) and [the testing policy](../../../../docs/testing.md) now name both SDKs as independent projections of the agent loop, session lifecycle, and `SessionEventMap`, so a change to any of those carries updating both expected outputs rather than only the one a contributor happens to run.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note:Python 极简组合的模型可见快照
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-13-python-minimal-model-visible-snapshot.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/jsonrpc-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。
|
||||
|
||||
## 决策
|
||||
|
||||
[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在期望输出需要重放的各平台上并不相同。
|
||||
|
||||
有一条模型可见消息被排除在外:agent loop 的动态运行时上下文快照。同一组合在 macOS 上会发出它,在必需车道所用的 Linux 上不会,因此任何单一期望输出都无法承载它。该差异本身就是缺陷([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))——这份期望输出覆盖其余全部模型可见消息,而不是等它先被修复。
|
||||
|
||||
mock 模型不再断言极简场景的工具与系统提示词——该面由快照拥有,并给出完整差异而非首个不匹配项。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**像进阶场景那样对极简会话日志做快照。** 极简回合驱动真实 PTY 与编辑器,持久化的工具结果带有平台相关文本。期望输出会因与模型可见组装无关的原因变红;而把这些文本归一化掉之后,日志所承载的内容也就所剩无几。
|
||||
|
||||
**扩展 mock 模型中的内联断言。** 每新增一项模型可见贡献都要再手写一条期望,且失败只会指出一处不匹配而非整个面。工具描述还会从组合复制进脚本,形成重复。
|
||||
|
||||
**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景固定了同一组合的系统提示词、工具 schema 与运行时上下文,但走的是重放的模型响应与 source 或 `lib` 运行时,且位于另一个必需任务中。它无法体现已部署可执行文件的闭包为 Python 调用方组装出什么。
|
||||
|
||||
## 后果
|
||||
|
||||
极简组合模型可见面的改动——系统分段、工具、工具描述或新增的 user 消息——现在会让 `python-runtime` 带着精确差异失败;要让它落地,就必须重新运行 `--scenario sdk-minimal --update-snapshots` 并审阅该差异。极简组合的工具描述由此成为经过审阅的期望输出。
|
||||
|
||||
assistant 与 tool 消息文本不再参与比对,运行时上下文快照则完全不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;被排除的那条消息由 [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) 负责,直到其平台差异得到解决。
|
||||
|
||||
[AGENTS.md](../../../../AGENTS.md) 与[测试政策](../../../../docs/testing.md)现已点明两个 SDK 都是 agent loop、会话生命周期与 `SessionEventMap` 的独立投影,因此改动其中任何一项都要连带更新两侧的期望输出,而不只是贡献者恰好会运行的那一侧。
|
||||
@@ -112,4 +112,12 @@ gh pr checks
|
||||
|
||||
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
|
||||
|
||||
When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=<sha>` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event:
|
||||
|
||||
```sh
|
||||
gh pr view <number> --json mergeable,mergeStateStatus
|
||||
```
|
||||
|
||||
GitHub creates no `pull_request` workflow runs while a PR is `CONFLICTING`/`DIRTY`, so the absent signal is the conflict, not infrastructure. Resolving the conflict is the only fix; empty commits, `--allow-empty` pushes, draft/ready toggles, and revert-and-restore bounces all leave `total_count` at zero and add junk history. Confirm the conflicting paths with `git merge-tree --write-tree HEAD origin/<base>` when the branch cannot be merged locally yet.
|
||||
|
||||
For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible.
|
||||
|
||||
@@ -194,6 +194,7 @@ jobs:
|
||||
*) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;;
|
||||
esac
|
||||
addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)"
|
||||
(cd "$addon_dir" && npm_config_build_from_source=true pnpm run install)
|
||||
addon="$addon_dir/build/Release/pty.node"
|
||||
[ -f "$addon_dir/build/Makefile" ] || {
|
||||
echo "::error::node-pty install did not generate $addon_dir/build/Makefile"
|
||||
|
||||
@@ -455,6 +455,9 @@ jobs:
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
DSH_COVERAGE_MAX_WORKERS: '2'
|
||||
# Instrumented process and polling fixtures can exceed Vitest's defaults
|
||||
# under the complete lane's concurrent gate load.
|
||||
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
|
||||
DSH_GATE_CONCURRENCY: '2'
|
||||
DSH_PUBLINT_CONCURRENCY: '8'
|
||||
steps:
|
||||
|
||||
@@ -123,6 +123,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
|
||||
- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
|
||||
- **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).
|
||||
- **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).
|
||||
- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)).
|
||||
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
||||
|
||||
@@ -93,7 +93,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch)
|
||||
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
|
||||
|
||||
## Official Claude Code platform payloads
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 4f22ed3da7de81f94d6fc5ee55a305d117c126e7
|
||||
config-catalog.zh.md: 7054ec8b52a8c46bc1ace97112f086119a61cfec
|
||||
config-catalog.md: 4da2774eb94eae3216c2cf89b7864a075872f9ee
|
||||
config-catalog.zh.md: 4f9e2cf3ecb454cbc34da565bdc23877c4b54401
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AcpConfig {
|
||||
|
||||
Depends on: `Stream` (`@agentclientprotocol/sdk`)
|
||||
|
||||
Source: [`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts)
|
||||
Source: [`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-acp-demo"></a>
|
||||
|
||||
@@ -3130,6 +3130,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
|
||||
- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
|
||||
- `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts))
|
||||
- `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
|
||||
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
|
||||
- `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface AcpConfig {
|
||||
|
||||
依赖:`Stream`(`@agentclientprotocol/sdk`)
|
||||
|
||||
来源:[`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts)
|
||||
来源:[`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-acp-demo"></a>
|
||||
|
||||
@@ -3131,6 +3131,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
|
||||
- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
|
||||
- `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts))
|
||||
- `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
|
||||
- `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
|
||||
- `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 24ee9a4815b4236336ae37f6c926dba4718dafb9
|
||||
module-graph.zh.md: d87feb946a43a2f9d391e7894b4a143af4e18406
|
||||
module-graph.md: 2eb7c748ee0bcf6eb63d200841e35f2606958a24
|
||||
module-graph.zh.md: 35a5615914711da1f52e2ecfb938c2e134f6afcb
|
||||
|
||||
@@ -160,6 +160,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_code_runtime["packages/code-runtime"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
pkg_code_runtime_python["code-runtime-python"]
|
||||
pkg_code_runtime_worker_thread["code-runtime-worker-thread"]
|
||||
end
|
||||
subgraph group_compaction["packages/compaction"]
|
||||
@@ -342,6 +343,7 @@ flowchart TD
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
pkg_code_runtime --> pkg_invariants
|
||||
pkg_code_runtime_python --> pkg_invariants
|
||||
pkg_e2b --> pkg_invariants
|
||||
pkg_sdk_jsonrpc_demo --> pkg_invariants
|
||||
pkg_host_directory_picker --> pkg_invariants
|
||||
@@ -637,7 +639,9 @@ flowchart TD
|
||||
pkg_session_query --> pkg_session_persistence
|
||||
pkg_session_query --> pkg_session_title
|
||||
pkg_acp --> pkg_agent
|
||||
pkg_acp --> pkg_attachment
|
||||
pkg_acp --> pkg_invariants
|
||||
pkg_acp --> pkg_llm
|
||||
pkg_acp --> pkg_session
|
||||
pkg_acp --> pkg_user_approval
|
||||
pkg_headless --> pkg_agent
|
||||
@@ -869,6 +873,7 @@ flowchart TD
|
||||
pkg_tool_lsp --> pkg_system_prompt
|
||||
pkg_tool_lsp --> pkg_timeout
|
||||
pkg_tool_lsp --> pkg_tools
|
||||
pkg_mcp_client --> pkg_attachment
|
||||
pkg_mcp_client --> pkg_invariants
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_subprocess
|
||||
@@ -1436,6 +1441,7 @@ flowchart TD
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
@@ -1522,7 +1528,7 @@ flowchart TD
|
||||
| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
@@ -1561,7 +1567,7 @@ flowchart TD
|
||||
| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
|
||||
@@ -162,6 +162,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_code_runtime["packages/code-runtime"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
pkg_code_runtime_python["code-runtime-python"]
|
||||
pkg_code_runtime_worker_thread["code-runtime-worker-thread"]
|
||||
end
|
||||
subgraph group_compaction["packages/compaction"]
|
||||
@@ -344,6 +345,7 @@ flowchart TD
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
pkg_code_runtime --> pkg_invariants
|
||||
pkg_code_runtime_python --> pkg_invariants
|
||||
pkg_e2b --> pkg_invariants
|
||||
pkg_sdk_jsonrpc_demo --> pkg_invariants
|
||||
pkg_host_directory_picker --> pkg_invariants
|
||||
@@ -639,7 +641,9 @@ flowchart TD
|
||||
pkg_session_query --> pkg_session_persistence
|
||||
pkg_session_query --> pkg_session_title
|
||||
pkg_acp --> pkg_agent
|
||||
pkg_acp --> pkg_attachment
|
||||
pkg_acp --> pkg_invariants
|
||||
pkg_acp --> pkg_llm
|
||||
pkg_acp --> pkg_session
|
||||
pkg_acp --> pkg_user_approval
|
||||
pkg_headless --> pkg_agent
|
||||
@@ -871,6 +875,7 @@ flowchart TD
|
||||
pkg_tool_lsp --> pkg_system_prompt
|
||||
pkg_tool_lsp --> pkg_timeout
|
||||
pkg_tool_lsp --> pkg_tools
|
||||
pkg_mcp_client --> pkg_attachment
|
||||
pkg_mcp_client --> pkg_invariants
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_subprocess
|
||||
@@ -1438,6 +1443,7 @@ flowchart TD
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
@@ -1524,7 +1530,7 @@ flowchart TD
|
||||
| [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
|
||||
@@ -1563,7 +1569,7 @@ flowchart TD
|
||||
| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
|
||||
attachment.md: 7955850a55967861e287e22db5bea94e4f80fda4
|
||||
attachment.zh.md: d58dee7b8809b97a409acac999cde91d96b81ed9
|
||||
attachment.md: 289adc225960b806752b420795df95b5892cea14
|
||||
attachment.zh.md: 37fb28cc2f5e1a51c053eb845b6a97df96abe35b
|
||||
|
||||
@@ -81,7 +81,7 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the packaged batch caller for base64 wire uploads: it enforces the count and aggregate-byte limits, validates the whole batch, then commits and returns references in caller order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `admitEncodedImages()` is the wire entry for base64 uploads: it enforces canonical base64, then delegates batch admission to `saveImages()`, which owns the count and aggregate-byte limits and the validate-all-before-save order. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -106,6 +106,16 @@ Immutable binary attachment service. Implementations validate bytes before publi
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate one ordered image batch before committing any member.
|
||||
* Validation failures start no writes; storage failures return no partial
|
||||
* references, although already published content-addressed objects may stay
|
||||
* unreachable until a future retention policy collects them.
|
||||
* @param inputs - encoded images in their owning message order.
|
||||
* @returns durable references in the exact input order.
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
@@ -123,5 +133,5 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts)
|
||||
Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -81,7 +81,7 @@ interface StoredImageAttachment {
|
||||
}
|
||||
```
|
||||
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 wire 上传的封装批量调用方:强制执行张数与聚合字节上限,先校验整个批量,再提交并按调用方顺序返回引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`admitEncodedImages()` 是面向 base64 上传的 wire 入口:强制执行规范 base64,随后把批量准入委托给 `saveImages()`,由后者负责张数与聚合字节上限以及先全量校验再保存的顺序。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -106,6 +106,16 @@ Immutable binary attachment service. Implementations validate bytes before publi
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate one ordered image batch before committing any member.
|
||||
* Validation failures start no writes; storage failures return no partial
|
||||
* references, although already published content-addressed objects may stay
|
||||
* unreachable until a future retention policy collects them.
|
||||
* @param inputs - encoded images in their owning message order.
|
||||
* @returns durable references in the exact input order.
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
@@ -123,5 +133,5 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts)
|
||||
Source: [`packages/attachment/attachment/src/index.ts:33`](../../packages/attachment/attachment/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/commands.md
|
||||
commands.md: eb08681a79b815a0c05fd0ef226e3415aaab0fe0
|
||||
commands.zh.md: 17f18ce41d8be67828037506ba235c39a88ab769
|
||||
commands.md: a9752915d4eae22d448b5480f2d50746fd02ec5f
|
||||
commands.zh.md: 2fe3d71bafaf6653251a8ab53832dd4701152194
|
||||
|
||||
@@ -184,7 +184,7 @@ find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
|
||||
Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
|
||||
|
||||
Source: [`packages/interaction/commands/src/index.ts:245`](../../packages/interaction/commands/src/index.ts)
|
||||
Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts)
|
||||
|
||||
<a id="commands-events"></a>
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ find(agent: Agent, name: string): CommandDefinition | undefined
|
||||
|
||||
Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
|
||||
|
||||
Source: [`packages/interaction/commands/src/index.ts:245`](../../packages/interaction/commands/src/index.ts)
|
||||
Source: [`packages/interaction/commands/src/index.ts:250`](../../packages/interaction/commands/src/index.ts)
|
||||
|
||||
<a id="commands-events"></a>
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/testing.md
|
||||
testing.md: 8ed81412a954bcd1d4b5d84eb4133ed081092764
|
||||
testing.zh.md: 5662db51847ba2f4e297d0bd3d4af0d024fa29fb
|
||||
testing.md: bef73983e48365f6655e4d4242f4f73223d971ed
|
||||
testing.zh.md: 7a5935165a335b4f3885092ef7b7b29f08f53c51
|
||||
|
||||
+1
-1
@@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
|
||||
|
||||
## When a snapshot test is required
|
||||
|
||||
Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation.
|
||||
Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. The two SDKs project the agent loop, session lifecycle, and `SessionEventMap` independently, so changing any of those updates both: `examples/jsonrpc-agent/tests/snapshots/` owns the TypeScript client; `scripts/snapshots/python-sdk-single-exe/` owns the Python client, which only the required `python-runtime` CI job runs. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation.
|
||||
|
||||
+1
-1
@@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身
|
||||
|
||||
## 何时需要快照测试
|
||||
|
||||
每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。浏览器渲染的 Web GUI 旅程使用上述 Web 应用快照套件。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。
|
||||
每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples/<name>/tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。浏览器渲染的 Web GUI 旅程使用上述 Web 应用快照套件。两个 SDK 各自独立地投影 agent loop、会话生命周期与 `SessionEventMap`,因此改动其中任何一项都要同时更新两者:`examples/jsonrpc-agent/tests/snapshots/` 拥有 TypeScript 客户端;`scripts/snapshots/python-sdk-single-exe/` 拥有 Python 客户端,且只有必需的 `python-runtime` CI 作业会运行它。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
|
||||
tool-catalog.md: 50563c97c6cd5496871ea7fa52c4823a56b088fd
|
||||
tool-catalog.zh.md: ed0c7e3f70cffbecd3d20a1556bcb0cd4204df00
|
||||
tool-catalog.md: 02501d0721768d38c1e51bdfb87dcc79696bfd04
|
||||
tool-catalog.zh.md: 3583662dbaf72ea78bddb6a2b7d6b18360f3f3e1
|
||||
|
||||
@@ -120,7 +120,7 @@ ask_user_question pauses the tool call until the active UI provider returns a hu
|
||||
|
||||
### `run_code`
|
||||
|
||||
Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.
|
||||
Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -122,7 +122,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
|
||||
|
||||
### `run_code`
|
||||
|
||||
针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`);以及 `description`,简要说明该程序做什么。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容会传回,请谨慎筛选。
|
||||
针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,即异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`);以及 `description`,简要说明该程序做什么。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容属于程序输出,请谨慎筛选。含图片的子工具结果会在运行结束后附加。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Keyless replay combines Code Mode with the durable image store and an exact
|
||||
# image-capable replay route. The scenario generates its tiny PNG inside the
|
||||
# run_code program, then exercises read_image as a nested dispatch.
|
||||
- id: base
|
||||
name: '@deepseek-ai/cordis-plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
disabled: true
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: none
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: code
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
- insert:
|
||||
- id: attachment-local
|
||||
name: '@deepseek-ai/dsh-attachment-local'
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker-thread'
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
inputModalities: [text, image]
|
||||
- id: deepseek-v4-pro
|
||||
inputModalities: [text]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Code Mode image overlay: mounts the worker runtime and durable attachment
|
||||
# store so a nested read_image result can cross the generic rich-result bridge.
|
||||
# The authored snapshot is replay-only; the live config retains the ordinary
|
||||
# exact provider route for manual use.
|
||||
- id: base
|
||||
name: '@deepseek-ai/cordis-plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-pro
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
tools:
|
||||
mode: code
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
- insert:
|
||||
- id: attachment-local
|
||||
name: '@deepseek-ai/dsh-attachment-local'
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker-thread'
|
||||
@@ -32,6 +32,7 @@ const AGENT = {
|
||||
// The Code Mode overlay configs (include-patched variants of cordis.yml; the
|
||||
// replay swap resolves each one's sibling `*cordis.snapshot.yml`).
|
||||
const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
|
||||
const CODE_MODE_IMAGE_CONFIG = fileURLToPath(new URL('../code-mode-image.cordis.yml', import.meta.url))
|
||||
const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
|
||||
const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
|
||||
const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../agent-instructions.cordis.yml', import.meta.url))
|
||||
@@ -212,6 +213,13 @@ const SCENARIOS: Scenario[] = [
|
||||
headerClass: 'image',
|
||||
configPath: IMAGE_TEXT_ROUTE_CONFIG,
|
||||
},
|
||||
{
|
||||
name: 'inline-image-prompt',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
headerClass: 'image',
|
||||
configPath: IMAGE_CONFIG,
|
||||
},
|
||||
{
|
||||
name: 'pty-tools',
|
||||
hasModelTurn: true,
|
||||
@@ -547,6 +555,16 @@ const SCENARIOS: Scenario[] = [
|
||||
// tools:sdk section rides in the prompt, and the program's tool calls land as
|
||||
// tool/code-dispatch events. Each overlay composes and pins its own header class.
|
||||
{ name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
|
||||
{
|
||||
name: 'code-mode-read-image',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
pinsHeader: true,
|
||||
headerClass: 'code-image',
|
||||
toolSchemasSource: 'code-mode-turn',
|
||||
configPath: CODE_MODE_IMAGE_CONFIG,
|
||||
posixOnly: true,
|
||||
},
|
||||
// A nested fs dispatch inside run_code discovers workspace instructions. The
|
||||
// projection enters the inbox after the outer result and becomes model-visible
|
||||
// on the following step, retaining workspace provenance end to end.
|
||||
|
||||
@@ -136,7 +136,7 @@ Use subagent in the background by default. Start independent delegations togethe
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
|
||||
@@ -443,7 +443,7 @@
|
||||
},
|
||||
{
|
||||
"name": "run_code",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -30,7 +30,7 @@ Use subagent in the background by default. Start independent delegations togethe
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
},
|
||||
{
|
||||
"name": "run_code",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "initialize"
|
||||
},
|
||||
{
|
||||
"op": "newSession"
|
||||
},
|
||||
{
|
||||
"op": "prompt",
|
||||
"text": "Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1783952000000,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1786431644501,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1786431644502,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1786431644502,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1786431644557,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}}
|
||||
{"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":17,"time":1786431644829,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"}}}
|
||||
{"type":"tool/code-dispatch","seq":18,"time":1786431644871,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"},"isError":false,"content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}]}}
|
||||
{"type":"tool/result","seq":19,"time":1786431644874,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"code-image-call"},"content":[{"type":"tool-result","toolCallId":"code-image-call","content":[{"type":"text","text":"{{cwd}}/red.png"}],"isError":false}],"role":"user","id":"73e999fa-4aab-4609-970d-4c675e3557f1"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"agent/inbox/spliced","seq":20,"time":1786431644874,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"99bca54a-c323-4df8-8695-7ef17d02dd65"}]}}
|
||||
{"type":"step/end","seq":21,"time":1786431644874,"data":{"turn":1,"step":1}}
|
||||
{"type":"agent/inbox/spliced","seq":22,"time":1786431644874,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":23,"time":1786431644884,"data":{"turn":1,"step":2}}
|
||||
{"type":"user/message","seq":24,"time":1786431644885,"data":{"content":[{"type":"text","text":"<path>{{cwd}}/red.png</path>\n<type>image</type>\n<content>\nimage/png image, 1x1 px, 69 bytes\n</content>"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"99bca54a-c323-4df8-8695-7ef17d02dd65"},"surfaceOp":"append"}
|
||||
{"type":"assistant/chunk","seq":25,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,459 @@
|
||||
You are an AI agent powered by DeepSeek Harness.
|
||||
|
||||
You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
|
||||
`run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.
|
||||
|
||||
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.
|
||||
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
|
||||
Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
|
||||
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
|
||||
|
||||
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
|
||||
|
||||
Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
|
||||
|
||||
## Writing code for run_code
|
||||
|
||||
`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program:
|
||||
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
```ts
|
||||
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
interface ToolArgsMap {
|
||||
/** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */
|
||||
bash: {
|
||||
/** The bash command to execute. */
|
||||
command: string;
|
||||
/** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */
|
||||
description: string;
|
||||
/** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */
|
||||
timeoutMs?: number;
|
||||
/** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */
|
||||
workdir?: string;
|
||||
/** Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies. */
|
||||
run_in_background?: boolean;
|
||||
/** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */
|
||||
create_goal: {
|
||||
/** The concrete completion objective inferred from the direct human request. */
|
||||
objective: string;
|
||||
/** Optional positive safe-integer limit on automatic continuation rounds. */
|
||||
max_goal_rounds?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Edit an existing UTF-8 text file by replacing literal text. */
|
||||
edit: {
|
||||
/** Path to edit, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** Literal text to replace. Must match exactly. */
|
||||
old_string: string;
|
||||
/** Literal replacement text. Use an empty string to delete the match. */
|
||||
new_string: string;
|
||||
/** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */
|
||||
replace_all?: boolean;
|
||||
/** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */
|
||||
get_goal: Record<string, JsonValue>;
|
||||
/** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */
|
||||
interrupt_agent: {
|
||||
/** The agent id of the running agent to interrupt. */
|
||||
agent_id: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */
|
||||
job_kill: {
|
||||
/** Job id returned by the tool that started the background work. */
|
||||
job_id: string;
|
||||
/** Optional short reason, recorded in the log and forwarded to the job. */
|
||||
reason?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your background jobs (running and finished) with their ids, kinds, and statuses. */
|
||||
job_list: Record<string, JsonValue>;
|
||||
/** Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */
|
||||
job_output: {
|
||||
/** Job id returned by the tool that started the background work. */
|
||||
job_id: string;
|
||||
/** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive. */
|
||||
wait?: boolean;
|
||||
/** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */
|
||||
timeout_ms?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */
|
||||
list_agents: {
|
||||
/** children (default) lists direct children only; descendants walks the complete tree below you. */
|
||||
scope?: "children" | "descendants";
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
|
||||
ralph: {
|
||||
/** The immutable completion objective for every fresh Ralph round. */
|
||||
objective: string;
|
||||
/** Optional positive safe-integer round cap, bounded by the deployment ceiling. */
|
||||
maxRounds?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read a UTF-8 text file and return line-numbered content. */
|
||||
read: {
|
||||
/** Path to read, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** 1-based first line to return. Defaults to 1. */
|
||||
offset?: number;
|
||||
/** Maximum number of lines to return. Defaults to 2000. */
|
||||
limit?: number;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. */
|
||||
read_image: {
|
||||
/** Path to the image file, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */
|
||||
send_message: {
|
||||
/** The subagent id returned when the background subagent was started. */
|
||||
subagent_id: string;
|
||||
/** The message to deliver to the subagent. */
|
||||
message: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */
|
||||
skill: {
|
||||
/** The exact skill name from the available skills list. */
|
||||
name: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */
|
||||
subagent: {
|
||||
/** A short (3-5 word) description of the delegated task, for display. */
|
||||
description: string;
|
||||
/** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */
|
||||
prompt: string;
|
||||
/** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */
|
||||
run_in_background?: boolean;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */
|
||||
subagent_fork: {
|
||||
/** A short (3-5 word) description of the delegated task, for display. */
|
||||
description: string;
|
||||
/** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */
|
||||
prompt: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */
|
||||
todo_write: {
|
||||
/** The COMPLETE task list, replacing any previous list. */
|
||||
todos: ({
|
||||
/** What the task is — a short imperative line. */
|
||||
content: string;
|
||||
/** pending (not started) | in_progress (now) | completed (done). */
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
})[];
|
||||
} & Record<string, JsonValue>;
|
||||
/** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */
|
||||
update_goal: {
|
||||
/** Exact id returned by get_goal. */
|
||||
goal_id: string;
|
||||
/** Exact positive revision returned by get_goal. */
|
||||
revision: number;
|
||||
/** edit | pause | resume | complete | blocked */
|
||||
action: "edit" | "pause" | "resume" | "complete" | "blocked";
|
||||
/** Replacement objective; valid only with action edit. */
|
||||
objective?: string;
|
||||
/** Replacement cap; valid only with action edit. */
|
||||
max_goal_rounds?: number;
|
||||
/** Concrete blocking condition; required only with action blocked. */
|
||||
blocked_reason?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */
|
||||
workflow: {
|
||||
/** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`). */
|
||||
script: string;
|
||||
/** The workflow identity block (plain JSON — never code). */
|
||||
meta: {
|
||||
/** Short kebab-case workflow name. */
|
||||
name: string;
|
||||
/** One-line description of what the workflow does. */
|
||||
description: string;
|
||||
/** Optional guidance on when this workflow applies. */
|
||||
whenToUse?: string;
|
||||
/** Optional phase declarations matched by phase() calls. */
|
||||
phases?: ({
|
||||
/** The phase title phase() calls match by exact string. */
|
||||
title: string;
|
||||
/** Optional one-line description of the phase. */
|
||||
detail?: string;
|
||||
/** Optional provider override this phase is expected to use. */
|
||||
provider?: string;
|
||||
/** Optional model override this phase is expected to use. */
|
||||
model?: string;
|
||||
} & Record<string, JsonValue>)[];
|
||||
} & Record<string, JsonValue>;
|
||||
/** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */
|
||||
args?: Record<string, JsonValue>;
|
||||
} & Record<string, JsonValue>;
|
||||
/** Create or fully replace a UTF-8 text file. */
|
||||
write: {
|
||||
/** Path to write, resolved by the filesystem backend. */
|
||||
file_path: string;
|
||||
/** Full UTF-8 text content to write. */
|
||||
content: string;
|
||||
/** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */
|
||||
sandbox_permissions?: "workspace-write" | "danger-full-access";
|
||||
/** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */
|
||||
justification?: string;
|
||||
} & Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
interface ToolOutputMap {
|
||||
bash: {
|
||||
kind: "background";
|
||||
jobId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
aborted: boolean;
|
||||
timeoutMs: number;
|
||||
stdout: {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
spillPath?: string;
|
||||
};
|
||||
stderr: {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
spillPath?: string;
|
||||
};
|
||||
sandbox?: {
|
||||
mode: string;
|
||||
denied: boolean;
|
||||
enforcement?: string;
|
||||
runnerFailed?: boolean;
|
||||
};
|
||||
};
|
||||
create_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
edit: {
|
||||
path: string;
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
get_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
interrupt_agent: {
|
||||
accepted: boolean;
|
||||
};
|
||||
job_kill: {
|
||||
outcome: "cancellation-requested" | "already-finished";
|
||||
job: {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
};
|
||||
};
|
||||
job_list: ({
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
})[];
|
||||
job_output: {
|
||||
text: string;
|
||||
job: {
|
||||
id: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
status: "running" | "stopping" | "completed" | "killed" | "failed";
|
||||
detail?: string;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
};
|
||||
};
|
||||
list_agents: ({
|
||||
kind: "child";
|
||||
id: string;
|
||||
label: string;
|
||||
status: "running" | "idle" | "ready";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
} | {
|
||||
kind: "diagnostic";
|
||||
id: string;
|
||||
reason: "corrupt" | "unsupported" | "unavailable";
|
||||
parent?: string;
|
||||
depth?: number;
|
||||
})[];
|
||||
ralph: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
result: JsonValue;
|
||||
};
|
||||
read: {
|
||||
path: string;
|
||||
offset: number;
|
||||
lines: {
|
||||
number: number;
|
||||
text: string;
|
||||
}[];
|
||||
totalLines: number;
|
||||
};
|
||||
read_image: {
|
||||
path: string;
|
||||
image: {
|
||||
attachmentId: string;
|
||||
mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
|
||||
bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
send_message: {
|
||||
messageId: string;
|
||||
};
|
||||
skill: {
|
||||
name: string;
|
||||
provider: string;
|
||||
resourceBase?: {
|
||||
kind: "directory";
|
||||
path: string;
|
||||
} | {
|
||||
kind: "url";
|
||||
url: string;
|
||||
} | {
|
||||
kind: "opaque";
|
||||
description: string;
|
||||
};
|
||||
content: string;
|
||||
};
|
||||
subagent: {
|
||||
kind: "background";
|
||||
jobId: string;
|
||||
} | {
|
||||
kind: "continuable";
|
||||
subagentId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
runId: string;
|
||||
output: JsonValue[];
|
||||
};
|
||||
subagent_fork: {
|
||||
kind: "background";
|
||||
jobId: string;
|
||||
} | {
|
||||
kind: "continuable";
|
||||
subagentId: string;
|
||||
} | {
|
||||
kind: "foreground";
|
||||
runId: string;
|
||||
output: JsonValue[];
|
||||
};
|
||||
todo_write: {
|
||||
todos: ({
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
})[];
|
||||
counts: {
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
};
|
||||
};
|
||||
update_goal: {
|
||||
goal: null;
|
||||
} | {
|
||||
goal: {
|
||||
id: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
phase: "active" | "paused" | "blocked" | "complete";
|
||||
roundsStarted: number;
|
||||
maxGoalRounds: number;
|
||||
blockedReason?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
activation: "armed" | "disarmed";
|
||||
};
|
||||
workflow: {
|
||||
runId: string;
|
||||
agentsStarted: number;
|
||||
result: JsonValue;
|
||||
};
|
||||
write: {
|
||||
path: string;
|
||||
operation: "create" | "update";
|
||||
before: string | null;
|
||||
after: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToolName = keyof ToolOutputMap
|
||||
|
||||
declare class ToolCallError extends Error {
|
||||
readonly name: "ToolCallError";
|
||||
readonly toolName: ToolName;
|
||||
}
|
||||
|
||||
declare const tools: {
|
||||
[K in ToolName]: (args: ToolArgsMap[K]) => Promise<ToolOutputMap[K]>;
|
||||
}
|
||||
```
|
||||
@@ -32,7 +32,7 @@ Use subagent in the background by default. Start independent delegations togethe
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"initial": [
|
||||
{
|
||||
"name": "run_code",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.",
|
||||
"description": "Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"op": "initialize"
|
||||
},
|
||||
{
|
||||
"op": "newSession"
|
||||
},
|
||||
{
|
||||
"op": "promptContent",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Inspect this image, then reply with exactly "
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
|
||||
"mimeType": "image/png"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "the single word DONE."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1783952000000,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1783952000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1783952000002,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1783952000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1783952000003,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -1,4 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
|
||||
@@ -461,6 +461,16 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/code-runtime/code-runtime-python": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"project": [
|
||||
"src/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/llm/llm-deepseek": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-root",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/acp/acp/README.md
|
||||
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
|
||||
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17
|
||||
README.md: aaabb0c824e12c250851985e92c0473f147e8efa
|
||||
README.zh.md: e722dbf06404f453dc746c7daf61d3e7b5b68fc2
|
||||
|
||||
+12
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
|
||||
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text/image prompts, collect committed assistant text/images, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
|
||||
|
||||
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules.
|
||||
|
||||
@@ -21,23 +21,23 @@ Both fields are optional so another agent/request listener may supply the target
|
||||
|
||||
| Method | Behavior |
|
||||
|---|---|
|
||||
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. |
|
||||
| `authenticate` | No-op because the server advertises no authentication methods. |
|
||||
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
|
||||
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
|
||||
| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission plus, once queued, whole-Agent idle and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. |
|
||||
| `session/cancel` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; unknown ids are no-ops. |
|
||||
| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. |
|
||||
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
|
||||
|
||||
One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer.
|
||||
|
||||
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text; reasoning and tool activity remain in the session log for observability through other interfaces.
|
||||
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text or images; reasoning and tool activity remain in the session log for observability through other interfaces. Per-session delivery is serialized because attachment reads are asynchronous, and a missing or corrupt committed image fails the prompt response instead of emitting a placeholder.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent.
|
||||
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately.
|
||||
ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -45,15 +45,15 @@ ACP requires each prompt response to carry a `stopReason`, but the bridge does n
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Prompt text
|
||||
### Prompt text and images
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`session/prompt` text blocks are concatenated verbatim into one user message; a baseline resource link appears in that message as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request.
|
||||
`session/prompt` preserves text/image order in one user message; adjacent text is concatenated, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
|
||||
Prompt tokens and image charges are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -76,6 +76,6 @@ Append-only through the owning tool result.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported.
|
||||
- **Baseline prompts and one workspace only** — images, audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content.
|
||||
- **Raster images and one workspace only** — image prompts require a durable store plus an exact route that declares image input; only PNG, JPEG, WebP, and GIF are accepted. Audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content.
|
||||
- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire.
|
||||
- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本/图片提示词、收集已提交的 assistant 文本/图片、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。
|
||||
|
||||
此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。
|
||||
|
||||
@@ -21,23 +21,23 @@
|
||||
|
||||
| 方法 | 行为 |
|
||||
|---|---|
|
||||
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
|
||||
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
|
||||
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 |
|
||||
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
|
||||
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
|
||||
| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入,以及消息入队后的整个 Agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
|
||||
| `session/cancel` | 标记并中止正在进行的准入,但不会取消或等待同一 Agent 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 Agent 并等待自有区间停稳。不发布迟到的用户消息,提示词以 `cancelled` 结算。没有进行中的提示词时会取消自主工作;未知 id 为空操作。 |
|
||||
| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 |
|
||||
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 |
|
||||
|
||||
一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。
|
||||
|
||||
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。
|
||||
已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本或图片;推理与工具活动仍保留在会话日志中,以便其他界面观测。由于附件读取是异步的,每个会话会串行交付内容;已提交图片缺失或损坏时,提示词响应会失败,而不会发出占位符。
|
||||
|
||||
## 生命周期
|
||||
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。
|
||||
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
|
||||
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束;inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时以 `end_turn` 结算;关联模型错误也只会在同一个完全停稳边界拒绝提示词。
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -45,15 +45,15 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 提示词文本
|
||||
### 提示词文本与图片
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
|
||||
`session/prompt` 会在一条用户消息中保留文本/图片顺序;相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。
|
||||
提示词 token 与图片费用取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -76,6 +76,6 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅新会话**:不支持加载、列出、恢复、删除和 fork。
|
||||
- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
|
||||
- **仅光栅图片和一个 workspace**:图片提示词要求持久存储以及明确声明支持图片输入的确切路由;只接受 PNG、JPEG、WebP 和 GIF。音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。
|
||||
- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。
|
||||
- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -36,13 +36,16 @@
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @module @deepseek-ai/dsh-acp/codec
|
||||
*/
|
||||
|
||||
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
||||
import type { StopReason } from '@agentclientprotocol/sdk'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
@@ -32,35 +32,3 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate
|
||||
* verbatim; resource links become explicit textual references so a baseline
|
||||
* client can point at files without the bridge silently dropping that context.
|
||||
* @param prompt - supported ACP prompt blocks.
|
||||
* @returns text in wire order, with resource links rendered as bracketed references.
|
||||
*/
|
||||
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
return prompt.flatMap((block): string[] => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [block.text]
|
||||
case 'resource_link':
|
||||
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a prompt carries content beyond the ACP baseline. The spec requires
|
||||
* every agent to accept `text` and `resource_link`; richer inline payloads
|
||||
* (image, audio, embedded resource) are optional capabilities this bridge does
|
||||
* not advertise, so they are rejected rather than silently dropped.
|
||||
* @param prompt - ACP prompt blocks to inspect.
|
||||
* @returns `true` when any block is neither `text` nor `resource_link`.
|
||||
*/
|
||||
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
||||
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/** ACP wire-content admission and projection owned by the ACP adapter. @module */
|
||||
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Raster formats shared by ACP image blocks and the core attachment vocabulary. */
|
||||
const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
|
||||
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
||||
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
/** Content-admission failure category used by the protocol handler. */
|
||||
export type AcpContentFailureKind = 'invalid' | 'internal'
|
||||
|
||||
/** Error with a stable ACP request-failure category and no raw binary payload. */
|
||||
export class AcpContentError extends Error {
|
||||
/** Whether the bridge should report invalid params or an internal failure. */
|
||||
readonly kind: AcpContentFailureKind
|
||||
|
||||
/**
|
||||
* @param message - safe protocol-facing detail without inline binary data.
|
||||
* @param kind - request-failure category.
|
||||
* @param options - optional causal chain for diagnostics.
|
||||
*/
|
||||
constructor(message: string, kind: AcpContentFailureKind, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AcpContentError'
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow a wire MIME string to the durable raster vocabulary. */
|
||||
function imageMediaType(value: string): ImageMediaType | undefined {
|
||||
return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType) ? value as ImageMediaType : undefined
|
||||
}
|
||||
|
||||
/** Strictly decode one ACP inline image without accepting base64 aliases. */
|
||||
function decodeImage(block: Extract<AcpContentBlock, { type: 'image' }>): SaveImageAttachment {
|
||||
const mediaType = imageMediaType(block.mimeType)
|
||||
if (mediaType === undefined) {
|
||||
throw new AcpContentError('image mimeType must be image/png, image/jpeg, image/webp, or image/gif', 'invalid')
|
||||
}
|
||||
if (!CANONICAL_BASE64.test(block.data)) {
|
||||
throw new AcpContentError('image data must be canonical base64', 'invalid')
|
||||
}
|
||||
const data = Buffer.from(block.data, 'base64')
|
||||
if (data.toString('base64') !== block.data) {
|
||||
throw new AcpContentError('image data must be canonical base64', 'invalid')
|
||||
}
|
||||
return { data, mediaType }
|
||||
}
|
||||
|
||||
/** Resolve the exact current route and require explicit image input support. */
|
||||
async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal): Promise<void> {
|
||||
const routed = agent.session.requestHeader()?.config
|
||||
const provider = routed?.provider ?? agent.options.provider
|
||||
const model = routed?.model ?? agent.options.model
|
||||
const llm = ctx.get('llm')
|
||||
if (provider === undefined || model === undefined || llm === undefined) {
|
||||
throw new AcpContentError('the current model route could not be resolved for image input', 'invalid')
|
||||
}
|
||||
let info: Awaited<ReturnType<typeof llm.resolveModelInfo>>
|
||||
try {
|
||||
info = await llm.resolveModelInfo(provider, model, signal)
|
||||
} catch (error: unknown) {
|
||||
throw new AcpContentError('the current model route could not be verified for image input', 'internal', { cause: error })
|
||||
}
|
||||
if (info.inputModalities === undefined || !info.inputModalities.includes('image')) {
|
||||
throw new AcpContentError(`model "${model}" does not declare image input`, 'invalid')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether initialization may truthfully advertise inline image prompts.
|
||||
* Unknown service, route, capability, or deployment media support is negative.
|
||||
* @param ctx - bridge context carrying optional attachment and model services.
|
||||
* @param provider - configured provider route used for newly created sessions.
|
||||
* @param model - configured exact model id used for newly created sessions.
|
||||
* @returns whether this bridge can admit images at initialization time.
|
||||
*/
|
||||
export async function supportsAcpImagePrompts(
|
||||
ctx: Context,
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
): Promise<boolean> {
|
||||
const attachments = ctx.get('attachments')
|
||||
const llm = ctx.get('llm')
|
||||
if (attachments === undefined || llm === undefined || provider === undefined || model === undefined) return false
|
||||
if (!attachments.imageLimits.mediaTypes.some(mediaType => IMAGE_MEDIA_TYPES.includes(mediaType))) return false
|
||||
try {
|
||||
const info = await llm.resolveModelInfo(provider, model)
|
||||
return info.inputModalities?.includes('image') === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one baseline resource link into the core's current text vocabulary. */
|
||||
function resourceLinkText(block: Extract<AcpContentBlock, { type: 'resource_link' }>): string {
|
||||
return `\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit one ACP prompt into ordered durable core content.
|
||||
* Every wire block and image is validated before the ordered image batch starts
|
||||
* writing; cancellation after a successful content-addressed write may leave an
|
||||
* unreachable object but never queues a late user message.
|
||||
* @param ctx - bridge context carrying attachment and model services.
|
||||
* @param agent - destination agent whose latest exact route controls admission.
|
||||
* @param prompt - untrusted ACP prompt blocks in wire order.
|
||||
* @param imageEnabled - capability result advertised during initialization.
|
||||
* @param signal - admission cancellation signal.
|
||||
* @returns core content with durable image references in wire order.
|
||||
*/
|
||||
export async function admitAcpPrompt(
|
||||
ctx: Context,
|
||||
agent: Agent,
|
||||
prompt: readonly AcpContentBlock[],
|
||||
imageEnabled: boolean,
|
||||
signal: AbortSignal,
|
||||
): Promise<ContentBlock[]> {
|
||||
const images: SaveImageAttachment[] = []
|
||||
for (const block of prompt) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'resource_link':
|
||||
break
|
||||
case 'image':
|
||||
if (!imageEnabled) throw new AcpContentError('inline image prompts were not advertised by this connection', 'invalid')
|
||||
images.push(decodeImage(block))
|
||||
break
|
||||
case 'audio':
|
||||
throw new AcpContentError('audio prompt content is not supported', 'invalid')
|
||||
case 'resource':
|
||||
throw new AcpContentError('embedded resource prompt content is not supported', 'invalid')
|
||||
/* v8 ignore next 2 -- ACP ContentBlock is a closed generated union. */
|
||||
default:
|
||||
throw new AcpContentError('unsupported ACP prompt content', 'invalid')
|
||||
}
|
||||
}
|
||||
|
||||
let refs: readonly ImageAttachmentRef[] = []
|
||||
if (images.length > 0) {
|
||||
const attachments = ctx.get('attachments')
|
||||
if (attachments === undefined) throw new AcpContentError('no attachment store is mounted', 'invalid')
|
||||
await assertImageRoute(ctx, agent, signal)
|
||||
signal.throwIfAborted()
|
||||
try {
|
||||
refs = await attachments.saveImages(images)
|
||||
} catch (error: unknown) {
|
||||
if (isImageAdmissionError(error)) {
|
||||
throw new AcpContentError(error.message, 'invalid', { cause: error })
|
||||
}
|
||||
throw new AcpContentError('unable to persist the prompt image batch', 'internal', { cause: error })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
const content: ContentBlock[] = []
|
||||
let pendingText = ''
|
||||
let imageIndex = 0
|
||||
const flushText = (): void => {
|
||||
if (pendingText.length === 0) return
|
||||
content.push({ type: 'text', text: pendingText })
|
||||
pendingText = ''
|
||||
}
|
||||
for (const block of prompt) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
pendingText += block.text
|
||||
break
|
||||
case 'resource_link':
|
||||
pendingText += resourceLinkText(block)
|
||||
break
|
||||
case 'image': {
|
||||
flushText()
|
||||
const ref = refs[imageIndex++] as ImageAttachmentRef
|
||||
content.push({ type: 'image', attachment: ref })
|
||||
break
|
||||
}
|
||||
/* v8 ignore start -- the validation pass above rejects both tags before reconstruction. */
|
||||
case 'audio':
|
||||
case 'resource':
|
||||
break
|
||||
/* v8 ignore stop */
|
||||
/* v8 ignore next 2 -- validated by the first closed-union switch. */
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
flushText()
|
||||
if (!content.some(block => block.type === 'image' || (block.type === 'text' && block.text.trim().length > 0))) {
|
||||
throw new AcpContentError('empty prompt', 'invalid')
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one committed assistant block to ACP wire content.
|
||||
* Images are re-read and integrity-verified before inline base64 delivery;
|
||||
* unsupported core output blocks stay off the automation wire.
|
||||
* @param ctx - bridge context carrying the authoritative attachment store.
|
||||
* @param block - committed core assistant block.
|
||||
* @returns ACP text/image content, or undefined for non-output blocks.
|
||||
*/
|
||||
export async function assistantBlockToAcp(
|
||||
ctx: Context,
|
||||
block: ContentBlock,
|
||||
): Promise<AcpContentBlock | undefined> {
|
||||
if (block.type === 'text') {
|
||||
return block.text.length === 0 ? undefined : { type: 'text', text: block.text }
|
||||
}
|
||||
if (block.type !== 'image') return undefined
|
||||
const attachments = ctx.get('attachments')
|
||||
if (attachments === undefined) {
|
||||
throw new AcpContentError('cannot deliver assistant image: no attachment store is mounted', 'internal')
|
||||
}
|
||||
let stored: Awaited<ReturnType<typeof attachments.readImage>>
|
||||
try {
|
||||
stored = await attachments.readImage(block.attachment)
|
||||
} catch (error: unknown) {
|
||||
throw new AcpContentError('cannot deliver assistant image: the attachment is unavailable or corrupt', 'internal', { cause: error })
|
||||
}
|
||||
return {
|
||||
type: 'image',
|
||||
data: Buffer.from(stored.data).toString('base64'),
|
||||
mimeType: stored.ref.mediaType,
|
||||
}
|
||||
}
|
||||
+212
-103
@@ -2,9 +2,9 @@
|
||||
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
|
||||
*
|
||||
* The bridge exposes fresh harness sessions to trusted programmatic clients. It
|
||||
* carries prompt text, committed assistant text, cancellation, and one-shot
|
||||
* permission decisions; presentation and human-interaction features stay with
|
||||
* the harness's UI modules.
|
||||
* carries prompt text/images, committed assistant text/images, cancellation,
|
||||
* and one-shot permission decisions; presentation and human-interaction
|
||||
* features stay with the harness's UI modules.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp
|
||||
*/
|
||||
@@ -37,7 +37,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: declaration-merges the approval waterfall answered below.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
|
||||
import { AcpContentError, admitAcpPrompt, assistantBlockToAcp, supportsAcpImagePrompts } from './content.ts'
|
||||
import { turnEndToStopReason } from './codec.ts'
|
||||
|
||||
export const name = 'acp'
|
||||
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
|
||||
@@ -86,14 +87,29 @@ interface SessionRecord {
|
||||
agent: Agent
|
||||
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
|
||||
dispose: () => Promise<void>
|
||||
/** In-flight prompt and its captured turn number for exact settlement. */
|
||||
/** Ordered assistant-output delivery; every task contains its own failure. */
|
||||
outputTail: Promise<void>
|
||||
/** In-flight admission/turn/output lifecycle for exact settlement. */
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
reject: (error: Error) => void
|
||||
messageId: string
|
||||
/** Set only after rich-content admission succeeds and the message is built. */
|
||||
messageId: string | undefined
|
||||
/** Whether this prompt has entered the Agent's durable inbox interval. */
|
||||
messageQueued: boolean
|
||||
turn: number | undefined
|
||||
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
|
||||
endReason: TurnEndReason | undefined
|
||||
/** Admission quiescence gate, including any attachment write already in progress. */
|
||||
admissionDone: Promise<void>
|
||||
finishAdmission: () => void
|
||||
admissionController: AbortController
|
||||
cancelRequested: boolean
|
||||
settlementStarted: boolean
|
||||
/** Conversion failure for committed output owned by this prompt's turn. */
|
||||
outputError: Error | undefined
|
||||
/** Interval-wide failure outside the correlated turn. */
|
||||
agentError: Error | undefined
|
||||
} | undefined
|
||||
}
|
||||
|
||||
@@ -110,6 +126,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const sessions = new Map<SessionId, SessionRecord>()
|
||||
let closed = false
|
||||
let conn: AgentSideConnection
|
||||
let imagePromptEnabled = false
|
||||
|
||||
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
||||
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
|
||||
@@ -127,19 +144,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
return record
|
||||
}
|
||||
|
||||
/** Send a protocol update without letting a disconnected client fail an agent turn. */
|
||||
const notify = (notification: SessionNotification): void => {
|
||||
/* v8 ignore next 3 -- only a transport write failure reaches this guard. */
|
||||
void conn.sessionUpdate(notification).catch((error: unknown) => {
|
||||
/** Send one ordered protocol update while containing transport-only failure. */
|
||||
const notify = async (notification: SessionNotification): Promise<void> => {
|
||||
try {
|
||||
await conn.sessionUpdate(notification)
|
||||
/* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */
|
||||
} catch (error: unknown) {
|
||||
logger.warn(`acp: session/update failed: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
|
||||
const inflight = record.inflight
|
||||
if (inflight === undefined) return
|
||||
record.inflight = undefined
|
||||
inflight.resolve(reason)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
const rejectFromError = (
|
||||
@@ -149,48 +162,91 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
|
||||
}
|
||||
|
||||
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||
// titles, and retry markers are presentation or trace data and stay off the
|
||||
// automation wire.
|
||||
/**
|
||||
* Settle one exact prompt only after admission, agent activity, and ordered
|
||||
* assistant delivery have all reached quiescence.
|
||||
*/
|
||||
const settleAfterQuiescence = (
|
||||
record: SessionRecord,
|
||||
inflight: NonNullable<SessionRecord['inflight']>,
|
||||
): void => {
|
||||
if (inflight.settlementStarted) return
|
||||
inflight.settlementStarted = true
|
||||
void (async () => {
|
||||
await inflight.admissionDone
|
||||
if (inflight.messageQueued) {
|
||||
await record.agent.whenIdle()
|
||||
// session/event enqueues synchronously before the agent becomes idle;
|
||||
// reading the live tail here includes every committed output task.
|
||||
await record.outputTail
|
||||
}
|
||||
/* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
if (inflight.cancelRequested) {
|
||||
inflight.resolve('cancelled')
|
||||
return
|
||||
}
|
||||
if (inflight.outputError !== undefined) {
|
||||
inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`))
|
||||
return
|
||||
}
|
||||
if (inflight.agentError !== undefined) {
|
||||
inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`))
|
||||
return
|
||||
}
|
||||
const end = inflight.endReason
|
||||
if (end === undefined) {
|
||||
inflight.resolve('cancelled')
|
||||
} else if (end.kind === 'error') {
|
||||
rejectFromError(inflight, end)
|
||||
} else {
|
||||
// Token-limit and other non-terminal endings are not prompt-level stop
|
||||
// reasons; ordinary quiescence reports end_turn.
|
||||
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
||||
}
|
||||
})()
|
||||
/* v8 ignore start -- admissionDone only resolves, and the queued path's idle/output gates contain their own failures. */
|
||||
.catch((error: unknown) => {
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`))
|
||||
})
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
// Emit only committed assistant text/images. Raw chunks, reasoning, tools,
|
||||
// plans, titles, and retry markers are presentation or trace data and stay
|
||||
// off the automation wire. One per-session chain preserves block/message
|
||||
// order across asynchronous attachment reads.
|
||||
ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
const record = sessions.get(session.header.id)
|
||||
if (record === undefined || record.agent.session !== session) return
|
||||
try {
|
||||
if (event.type === 'assistant/message') {
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'text' && block.text.length > 0) {
|
||||
notify({
|
||||
const inflight = record.inflight?.turn === event.data.turn ? record.inflight : undefined
|
||||
const previous = record.outputTail
|
||||
const delivery = previous.then(async () => {
|
||||
for (const block of event.data.message.content) {
|
||||
const content = await assistantBlockToAcp(ctx, block)
|
||||
if (content === undefined) continue
|
||||
await notify({
|
||||
sessionId: record.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: block.text },
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'image') {
|
||||
notify({
|
||||
sessionId: record.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[image attachment ${block.attachment.attachmentId}]`,
|
||||
},
|
||||
},
|
||||
update: { sessionUpdate: 'agent_message_chunk', content },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
record.outputTail = delivery.catch((error: unknown) => {
|
||||
// assistantBlockToAcp owns conversion failures and always throws Error.
|
||||
const failure = error as Error
|
||||
if (inflight !== undefined) inflight.outputError ??= failure
|
||||
logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
const inflight = record.inflight
|
||||
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
|
||||
if (event.data.reason.kind === 'error') {
|
||||
// Model failures surface immediately as prompt errors; ordinary
|
||||
// endings wait for whole-agent idle below.
|
||||
record.inflight = undefined
|
||||
rejectFromError(inflight, event.data.reason)
|
||||
} else {
|
||||
inflight.endReason = event.data.reason
|
||||
}
|
||||
inflight.endReason = event.data.reason
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -204,9 +260,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
ctx.on('agent/error', ({ agent, turn, error }) => {
|
||||
const record = ownedRecord(agent)
|
||||
const inflight = record?.inflight
|
||||
if (record === undefined || inflight === undefined || inflight.turn === turn) return
|
||||
record.inflight = undefined
|
||||
inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
|
||||
if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return
|
||||
inflight.agentError = new Error(errorChain(error))
|
||||
settleAfterQuiescence(record, inflight)
|
||||
})
|
||||
|
||||
// Permission requests are a machine policy channel for ACP clients such as
|
||||
@@ -231,17 +287,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
|
||||
conn = connection
|
||||
return {
|
||||
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
// Single-version agent: the spec's "same version if supported, else
|
||||
// the latest supported" both resolve to this server's one version.
|
||||
return Promise.resolve({
|
||||
imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model)
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false },
|
||||
},
|
||||
authMethods: [],
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
@@ -269,6 +326,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
sessions.set(sessionId, {
|
||||
agent: handle.agent,
|
||||
dispose: () => handle.dispose(),
|
||||
outputTail: Promise.resolve(),
|
||||
inflight: undefined,
|
||||
})
|
||||
return { sessionId }
|
||||
@@ -280,66 +338,103 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (record.inflight !== undefined) {
|
||||
throw invalidParams('a prompt is already in flight for this session')
|
||||
}
|
||||
if (promptHasUnsupportedContent(params.prompt)) {
|
||||
throw invalidParams('only text and resource_link prompt content is supported')
|
||||
const completion = Promise.withResolvers<StopReason>()
|
||||
const admission = Promise.withResolvers<void>()
|
||||
const admissionController = new AbortController()
|
||||
const inflight: NonNullable<SessionRecord['inflight']> = {
|
||||
resolve: completion.resolve,
|
||||
reject: completion.reject,
|
||||
messageId: undefined,
|
||||
messageQueued: false,
|
||||
turn: undefined,
|
||||
endReason: undefined,
|
||||
admissionDone: admission.promise,
|
||||
finishAdmission: admission.resolve,
|
||||
admissionController,
|
||||
cancelRequested: false,
|
||||
settlementStarted: false,
|
||||
outputError: undefined,
|
||||
agentError: undefined,
|
||||
}
|
||||
const text = acpPromptToText(params.prompt)
|
||||
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
||||
// Reserve the one-prompt slot before the first asynchronous route or
|
||||
// attachment operation so concurrent prompts and cancellation observe
|
||||
// admission as genuinely in flight.
|
||||
record.inflight = inflight
|
||||
|
||||
// Not driving a retired agent is this bridge's contract: an
|
||||
// agent-loop-only reload disposes the loop's agents while the bridge
|
||||
// record survives, so validate the record against the live registry
|
||||
// before sending — a disposed machine would accept the item silently.
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
// Arm the slot before followup() so a listener-driven synchronous
|
||||
// turn cannot slip past correlation; a synchronous followup()
|
||||
// failure (invalid input) must free the slot again or the session
|
||||
// would reject every later prompt as already in flight.
|
||||
const inflight: NonNullable<SessionRecord['inflight']> = {
|
||||
resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
|
||||
let admissionFailed = false
|
||||
let admissionFailure: unknown
|
||||
try {
|
||||
// Do not persist rich content for a retired destination. Re-check
|
||||
// after admission too because an agent-loop reload may race storage.
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
record.inflight = inflight
|
||||
const content = await admitAcpPrompt(
|
||||
ctx,
|
||||
record.agent,
|
||||
params.prompt,
|
||||
imagePromptEnabled,
|
||||
admissionController.signal,
|
||||
)
|
||||
// No await may separate this final abort check from followup: a
|
||||
// cancellation that wins admission must never enqueue a late turn.
|
||||
admissionController.signal.throwIfAborted()
|
||||
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
||||
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
||||
}
|
||||
const message = createUserMessage({ content, source: { kind: 'user' } })
|
||||
inflight.messageId = message.id
|
||||
inflight.messageQueued = true
|
||||
try {
|
||||
record.agent.followup(message)
|
||||
// The machine's send() contains listener failures and accepts
|
||||
// any typed input; this guards a future synchronous throw so the
|
||||
// slot cannot wedge.
|
||||
/* v8 ignore start -- future-proofing guard, see above */
|
||||
} catch (error: unknown) {
|
||||
record.inflight = undefined
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw internalError(`prompt was not queued: ${detail}`)
|
||||
// The typed same-process seam may fail synchronously before durable
|
||||
// inbox receipt; restore the pre-operation boundary for mapping.
|
||||
inflight.messageQueued = false
|
||||
throw error
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
// Settlement waits for whole-agent idle: a correlated turn/end arms
|
||||
// `endReason`, while a turnless slot (admission discarded the
|
||||
// prompt) stays cancelled. Other producers may run further turns
|
||||
// before quiescence; the prompt settles only when the agent stops.
|
||||
void record.agent.whenIdle().then(() => {
|
||||
if (record.inflight !== inflight) return
|
||||
record.inflight = undefined
|
||||
const end = inflight.endReason
|
||||
if (end === undefined) {
|
||||
inflight.resolve('cancelled')
|
||||
} else {
|
||||
// Token-limit and other non-terminal endings are not prompt-level
|
||||
// stop reasons (see README); only normal quiescence reports end_turn.
|
||||
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
admissionFailed = true
|
||||
admissionFailure = error
|
||||
} finally {
|
||||
inflight.finishAdmission()
|
||||
}
|
||||
|
||||
if (inflight.cancelRequested) {
|
||||
settleAfterQuiescence(record, inflight)
|
||||
return { stopReason: await completion.promise }
|
||||
}
|
||||
if (admissionFailed) {
|
||||
record.inflight = undefined
|
||||
if (admissionFailure instanceof AcpContentError) {
|
||||
throw admissionFailure.kind === 'invalid'
|
||||
? invalidParams(admissionFailure.message)
|
||||
: internalError(admissionFailure.message)
|
||||
}
|
||||
if (admissionFailure instanceof RequestError) throw admissionFailure
|
||||
// The admission codec and same-process agent seam throw Error values.
|
||||
const detail = (admissionFailure as Error).message
|
||||
throw internalError(`prompt was not queued: ${detail}`)
|
||||
}
|
||||
|
||||
settleAfterQuiescence(record, inflight)
|
||||
const stopReason = await completion.promise
|
||||
return { stopReason }
|
||||
},
|
||||
|
||||
cancel(params: CancelNotification): Promise<void> {
|
||||
const record = sessions.get(SessionId(params.sessionId))
|
||||
if (record === undefined) return Promise.resolve()
|
||||
record.agent.cancel({ kind: 'user' })
|
||||
settlePrompt(record, 'cancelled')
|
||||
const inflight = record.inflight
|
||||
if (inflight !== undefined) {
|
||||
inflight.cancelRequested = true
|
||||
inflight.admissionController.abort(new Error('ACP prompt cancelled'))
|
||||
settleAfterQuiescence(record, inflight)
|
||||
}
|
||||
// Admission is not Agent work. Preserve unrelated producers until this
|
||||
// prompt has entered the durable inbox; without a prompt, cancellation
|
||||
// continues to target autonomous work on the addressed Agent.
|
||||
if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' })
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
@@ -362,10 +457,24 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// on persistence or scoped cleanup, and the top-level agents must not keep
|
||||
// running model and tool calls for its whole duration.
|
||||
for (const record of records) {
|
||||
const inflight = record.inflight
|
||||
if (inflight !== undefined) {
|
||||
inflight.cancelRequested = true
|
||||
inflight.admissionController.abort(new Error('ACP bridge disposed'))
|
||||
settleAfterQuiescence(record, inflight)
|
||||
}
|
||||
record.agent.cancel({ kind: 'user' })
|
||||
settlePrompt(record, 'cancelled')
|
||||
}
|
||||
quiescing = (async () => {
|
||||
// Preserve the same prompt boundary during connection teardown: a rich
|
||||
// admission already writing must stop before its slot settles, and every
|
||||
// committed output conversion must drain while attachment services remain
|
||||
// available. session/event enqueues output synchronously before idle.
|
||||
await Promise.all(records.map(async (record) => {
|
||||
await record.inflight?.admissionDone
|
||||
await record.agent.whenIdle()
|
||||
await record.outputTail
|
||||
}))
|
||||
// Continuable subagents outlive the turn that started them, and their
|
||||
// Activations own descendant teardown. Drain only these sessions' forests
|
||||
// child-first BEFORE disposing the top-level agents, so no descendant is
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
@@ -28,6 +29,17 @@ describe('automation-only ACP bridge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('advertises image prompts only with an exact capable route and attachment store', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true })
|
||||
const capable = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(capable.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
await harness.dispose()
|
||||
|
||||
harness = await makeBridgeHarness({ imageCapable: true, attachments: false })
|
||||
const noStore = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(noStore.agentCapabilities?.promptCapabilities?.image).toBe(false)
|
||||
})
|
||||
|
||||
it('negotiates an unsupported version and accepts the required no-op authentication call', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} })
|
||||
@@ -77,6 +89,73 @@ describe('automation-only ACP bridge', () => {
|
||||
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }])
|
||||
})
|
||||
|
||||
it('admits mixed text/image prompts in wire order and logs references only', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const resolve = vi.spyOn(harness.ctx.llm, 'resolveModelInfo')
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
||||
{ type: 'text', text: 'between' },
|
||||
{ type: 'image', data: 'Ag==', mimeType: 'image/jpeg' },
|
||||
{ type: 'text', text: 'after' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith('mock', 'mock', expect.any(AbortSignal))
|
||||
expect(harness.attachments?.saved.map(input => [...input.data])).toEqual([[1], [2]])
|
||||
const requestContent = harness.adapter.requests[0]?.messages.at(-1)?.content
|
||||
expect(requestContent?.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
|
||||
expect(requestContent?.[0]).toEqual({ type: 'text', text: 'before' })
|
||||
expect(requestContent?.[2]).toEqual({ type: 'text', text: 'between' })
|
||||
expect(requestContent?.[4]).toEqual({ type: 'text', text: 'after' })
|
||||
const firstImage = requestContent?.[1]
|
||||
const secondImage = requestContent?.[3]
|
||||
if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks')
|
||||
expect(firstImage.attachment.mediaType).toBe('image/png')
|
||||
expect(firstImage.attachment.bytes).toBe(1)
|
||||
expect(secondImage.attachment.mediaType).toBe('image/jpeg')
|
||||
expect(secondImage.attachment.bytes).toBe(1)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))
|
||||
expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==')
|
||||
})
|
||||
|
||||
it('rejects a malformed image batch atomically and frees the prompt slot', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('recovered')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
||||
{ type: 'image', data: 'not base64', mimeType: 'image/png' },
|
||||
],
|
||||
})).rejects.toThrow(/canonical base64/)
|
||||
expect(harness.attachments?.saved).toEqual([])
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'retry' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('reports durable image write failures as internal prompt failures', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
vi.spyOn(harness.attachments!, 'saveImages').mockRejectedValueOnce(
|
||||
new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'),
|
||||
)
|
||||
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})).rejects.toThrow(/unable to persist the prompt image batch/)
|
||||
})
|
||||
|
||||
it('renders the deployment persona for an ACP-created agent', async () => {
|
||||
harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
@@ -107,7 +186,7 @@ describe('automation-only ACP bridge', () => {
|
||||
})).resolves.toHaveProperty('sessionId')
|
||||
})
|
||||
|
||||
it('rejects empty and beyond-baseline prompts before a turn starts', async () => {
|
||||
it('rejects empty and unadvertised image prompts before a turn starts', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
@@ -117,7 +196,7 @@ describe('automation-only ACP bridge', () => {
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
|
||||
})).rejects.toThrow(/only text and resource_link/)
|
||||
})).rejects.toThrow(/inline image prompts were not advertised/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts'
|
||||
import { turnEndToStopReason } from '../src/codec.ts'
|
||||
|
||||
describe('ACP codec', () => {
|
||||
it.each([
|
||||
@@ -13,12 +13,4 @@ describe('ACP codec', () => {
|
||||
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
|
||||
expect(turnEndToStopReason(reason)).toBe(expected)
|
||||
})
|
||||
|
||||
it('drops unsupported blocks from baseline text conversion', () => {
|
||||
expect(acpPromptToText([{
|
||||
type: 'image',
|
||||
data: '',
|
||||
mimeType: 'image/png',
|
||||
}])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
AcpContentError,
|
||||
admitAcpPrompt,
|
||||
assistantBlockToAcp,
|
||||
supportsAcpImagePrompts,
|
||||
} from '../src/content.ts'
|
||||
|
||||
const REF: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'1'.repeat(64)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
|
||||
interface AdmissionFixture {
|
||||
ctx: Context
|
||||
agent: Agent
|
||||
saveImages: ReturnType<typeof vi.fn<(inputs: readonly SaveImageAttachment[]) => Promise<readonly ImageAttachmentRef[]>>>
|
||||
resolveModelInfo: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function admissionFixture(options: {
|
||||
attachments?: boolean
|
||||
llm?: boolean
|
||||
provider?: string | undefined
|
||||
model?: string | undefined
|
||||
header?: { provider?: string; model?: string }
|
||||
} = {}): AdmissionFixture {
|
||||
const saveImages = vi.fn(async (inputs: readonly SaveImageAttachment[]) => inputs.map((input, index) => ({
|
||||
...REF,
|
||||
attachmentId: AttachmentId(`sha256:${String(index + 1).padStart(64, '0')}`),
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
})))
|
||||
const resolveModelInfo = vi.fn(async (provider: string, model: string) => ({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
inputModalities: ['text', 'image'] as const,
|
||||
}))
|
||||
const attachments = options.attachments === false ? undefined : { saveImages }
|
||||
const llm = options.llm === false ? undefined : { resolveModelInfo }
|
||||
const ctx = {
|
||||
get(name: string) {
|
||||
if (name === 'attachments') return attachments
|
||||
if (name === 'llm') return llm
|
||||
return undefined
|
||||
},
|
||||
} as unknown as Context
|
||||
const provider = 'provider' in options ? options.provider : 'mock'
|
||||
const model = 'model' in options ? options.model : 'vision'
|
||||
const agent = {
|
||||
options: { provider, model },
|
||||
session: { requestHeader: () => options.header === undefined ? undefined : { config: options.header } },
|
||||
} as unknown as Agent
|
||||
return { ctx, agent, saveImages, resolveModelInfo }
|
||||
}
|
||||
|
||||
describe('ACP rich content codec', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('advertises image input only when every deployment prerequisite is explicit', async () => {
|
||||
const absent = (attachments: unknown, llm: unknown): Context => ({
|
||||
get: (name: string) => name === 'attachments' ? attachments : name === 'llm' ? llm : undefined,
|
||||
}) as unknown as Context
|
||||
const store = { imageLimits: { mediaTypes: ['image/png'] } }
|
||||
const noMediaStore = { imageLimits: { mediaTypes: [] } }
|
||||
const imageLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text', 'image'] }) }
|
||||
const textLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text'] }) }
|
||||
const unknownLlm = { resolveModelInfo: vi.fn().mockResolvedValue({}) }
|
||||
const brokenLlm = { resolveModelInfo: vi.fn().mockRejectedValue(new Error('catalog down')) }
|
||||
|
||||
await expect(supportsAcpImagePrompts(absent(undefined, imageLlm), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, undefined), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, imageLlm), undefined, 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', undefined)).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(noMediaStore, imageLlm), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, brokenLlm), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, unknownLlm), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, textLlm), 'p', 'm')).resolves.toBe(false)
|
||||
await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', 'm')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('validates every rich wire block before any image write', async () => {
|
||||
const fixture = admissionFixture()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'image', data: 'AQ==', mimeType: 'image/tiff' },
|
||||
] as never, true, signal)).rejects.toThrow(/mimeType/)
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'image', data: 'not base64', mimeType: 'image/png' },
|
||||
], true, signal)).rejects.toThrow(/canonical base64/)
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'image', data: 'AB==', mimeType: 'image/png' },
|
||||
], true, signal)).rejects.toThrow(/canonical base64/)
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'audio', data: 'AQ==', mimeType: 'audio/wav' },
|
||||
], true, signal)).rejects.toThrow(/audio prompt/)
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'resource', resource: { uri: 'file:///tmp/a', text: 'a' } },
|
||||
], true, signal)).rejects.toThrow(/embedded resource/)
|
||||
expect(fixture.saveImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires the advertised capability, store, and exact image-capable route', async () => {
|
||||
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
|
||||
const capable = admissionFixture()
|
||||
await expect(admitAcpPrompt(capable.ctx, capable.agent, prompt, false, new AbortController().signal))
|
||||
.rejects.toThrow(/not advertised/)
|
||||
|
||||
const noStore = admissionFixture({ attachments: false })
|
||||
await expect(admitAcpPrompt(noStore.ctx, noStore.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/no attachment store/)
|
||||
|
||||
const noProvider = admissionFixture({ provider: undefined })
|
||||
await expect(admitAcpPrompt(noProvider.ctx, noProvider.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/route could not be resolved/)
|
||||
const noModel = admissionFixture({ model: undefined })
|
||||
await expect(admitAcpPrompt(noModel.ctx, noModel.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/route could not be resolved/)
|
||||
const noLlm = admissionFixture({ llm: false })
|
||||
await expect(admitAcpPrompt(noLlm.ctx, noLlm.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/route could not be resolved/)
|
||||
|
||||
const broken = admissionFixture()
|
||||
broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down'))
|
||||
const routeFailure = admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal)
|
||||
await expect(routeFailure).rejects.toMatchObject({ kind: 'internal' })
|
||||
await expect(routeFailure).rejects.toThrow(/route could not be verified/)
|
||||
const unknown = admissionFixture()
|
||||
unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' })
|
||||
await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/does not declare image input/)
|
||||
const textOnly = admissionFixture()
|
||||
textOnly.resolveModelInfo.mockResolvedValueOnce({
|
||||
provider: 'mock', id: 'vision', name: 'vision', inputModalities: ['text'],
|
||||
})
|
||||
await expect(admitAcpPrompt(textOnly.ctx, textOnly.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toThrow(/does not declare image input/)
|
||||
|
||||
const routed = admissionFixture({ provider: 'fallback', model: 'fallback', header: { provider: 'live', model: 'vision-2' } })
|
||||
await expect(admitAcpPrompt(routed.ctx, routed.agent, prompt, true, new AbortController().signal)).resolves.toHaveLength(1)
|
||||
expect(routed.resolveModelInfo).toHaveBeenCalledWith('live', 'vision-2', expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('classifies image-policy failures separately from durable write failures', async () => {
|
||||
const fixture = admissionFixture()
|
||||
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
|
||||
fixture.saveImages.mockRejectedValueOnce(new AttachmentError('too many', 'TOO_MANY_IMAGES'))
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toMatchObject({ kind: 'invalid', message: 'too many' })
|
||||
fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' })
|
||||
fixture.saveImages.mockRejectedValueOnce(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' })
|
||||
fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure'))
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal))
|
||||
.rejects.toBeInstanceOf(AcpContentError)
|
||||
})
|
||||
|
||||
it('honors cancellation on both sides of the durable image write', async () => {
|
||||
const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const
|
||||
const before = admissionFixture()
|
||||
const beforeController = new AbortController()
|
||||
beforeController.abort(new Error('cancel before write'))
|
||||
await expect(admitAcpPrompt(before.ctx, before.agent, prompt, true, beforeController.signal))
|
||||
.rejects.toThrow('cancel before write')
|
||||
expect(before.saveImages).not.toHaveBeenCalled()
|
||||
|
||||
const after = admissionFixture()
|
||||
const afterController = new AbortController()
|
||||
after.saveImages.mockImplementationOnce(async () => {
|
||||
afterController.abort(new Error('cancel after write'))
|
||||
return [REF]
|
||||
})
|
||||
await expect(admitAcpPrompt(after.ctx, after.agent, prompt, true, afterController.signal))
|
||||
.rejects.toThrow('cancel after write')
|
||||
expect(after.saveImages).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reconstructs image-only and baseline prompts without empty text blocks', async () => {
|
||||
const fixture = admissionFixture()
|
||||
const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
||||
], true, new AbortController().signal)
|
||||
expect(imageOnly).toHaveLength(1)
|
||||
expect(imageOnly[0]?.type).toBe('image')
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'resource_link', name: 'Guide', uri: 'https://example.test/guide' },
|
||||
{ type: 'text', text: 'after' },
|
||||
], true, new AbortController().signal)).resolves.toEqual([{
|
||||
type: 'text',
|
||||
text: 'before\n[resource_link name="Guide" uri="https://example.test/guide"]\nafter',
|
||||
}])
|
||||
await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [
|
||||
{ type: 'text', text: ' \n ' },
|
||||
], true, new AbortController().signal)).rejects.toThrow(/empty prompt/)
|
||||
})
|
||||
|
||||
it('projects only non-empty text and verified durable images to ACP', async () => {
|
||||
const fixture = admissionFixture()
|
||||
await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: '' })).resolves.toBeUndefined()
|
||||
await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: 'hello' })).resolves.toEqual({
|
||||
type: 'text', text: 'hello',
|
||||
})
|
||||
await expect(assistantBlockToAcp(fixture.ctx, { type: 'reasoning', text: 'private' })).resolves.toBeUndefined()
|
||||
|
||||
const noStore = admissionFixture({ attachments: false })
|
||||
await expect(assistantBlockToAcp(noStore.ctx, { type: 'image', attachment: REF }))
|
||||
.rejects.toThrow(/no attachment store/)
|
||||
const readImage = vi.fn().mockRejectedValue(new AttachmentError('gone', 'ATTACHMENT_NOT_FOUND'))
|
||||
const missingCtx = { get: (name: string) => name === 'attachments' ? { readImage } : undefined } as unknown as Context
|
||||
await expect(assistantBlockToAcp(missingCtx, { type: 'image', attachment: REF }))
|
||||
.rejects.toThrow(/unavailable or corrupt/)
|
||||
const storedCtx = {
|
||||
get: (name: string) => name === 'attachments'
|
||||
? { readImage: vi.fn().mockResolvedValue({ ref: REF, data: Uint8Array.of(1) }) }
|
||||
: undefined,
|
||||
} as unknown as Context
|
||||
await expect(assistantBlockToAcp(storedCtx, { type: 'image', attachment: REF })).resolves.toEqual({
|
||||
type: 'image', data: 'AQ==', mimeType: 'image/png',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
@@ -26,6 +27,37 @@ describe('ACP connection ownership', () => {
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => {
|
||||
const script: StreamChunk[][] = []
|
||||
harness = await makeBridgeHarness({ script })
|
||||
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' })
|
||||
script.push([
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'image', attachment: ref } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const readStarted = Promise.withResolvers<undefined>()
|
||||
const releaseRead = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeRead = () => {
|
||||
readStarted.resolve(undefined)
|
||||
return releaseRead.promise
|
||||
}
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
|
||||
await readStarted.promise
|
||||
|
||||
let disposed = false
|
||||
const disposal = harness.acpFiber.dispose().finally(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
releaseRead.resolve(undefined)
|
||||
await disposal
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drains continuable subagents before disposing its own sessions', async () => {
|
||||
harness = await makeBridgeHarness()
|
||||
const order: string[] = []
|
||||
|
||||
@@ -54,6 +54,51 @@ describe('ACP automation output boundary', () => {
|
||||
expect(harness.updates).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delivers output from a bridge-owned session driven by another in-process producer', async () => {
|
||||
harness = await makeBridgeHarness({ script: [textResponse('external')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(harness.updates).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'external' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('contains output conversion failure outside an ACP prompt', async () => {
|
||||
harness = await makeBridgeHarness({ script: [[
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId: `sha256:${'a'.repeat(64)}` as never,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]] })
|
||||
const warn = vi.spyOn(harness.ctx.logger, 'warn')
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
await agent.whenIdle()
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('output conversion failed')) })
|
||||
expect(harness.updates).toEqual([])
|
||||
})
|
||||
|
||||
// `session/update` is a JSON-RPC notification, so a client-side handler
|
||||
// failure never reaches the bridge; this pins that the prompt still settles
|
||||
// normally with such a client. The bridge's own write-failure guard is
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** In-memory ACP transport fixture over the real agent factory and loop. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -11,7 +12,9 @@ import {
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
@@ -21,7 +24,10 @@ import type { AcpConfig } from '../src/index.ts'
|
||||
class MockAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private readonly script: (StreamChunk[] | 'hang')[]) {
|
||||
constructor(
|
||||
private readonly script: (StreamChunk[] | 'hang')[],
|
||||
private readonly imageCapable: boolean,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -31,7 +37,21 @@ class MockAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
override listModels(provider: string) {
|
||||
return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : [])
|
||||
return Promise.resolve(provider === 'mock' ? [{
|
||||
provider: 'mock',
|
||||
id: 'mock',
|
||||
name: 'Mock',
|
||||
inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const,
|
||||
}] : [])
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
inputModalities: this.imageCapable ? ['text', 'image'] : ['text'],
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
@@ -57,6 +77,49 @@ class MockAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE_LIMITS: ImageAttachmentLimits = {
|
||||
maxImageBytes: 1024,
|
||||
maxImagesPerMessage: 4,
|
||||
maxMessageImageBytes: 2048,
|
||||
maxImagePixels: 1024,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
}
|
||||
|
||||
/** In-memory durable store for ACP wire-order and lifecycle tests. */
|
||||
class MemoryAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits = IMAGE_LIMITS
|
||||
readonly saved: SaveImageAttachment[] = []
|
||||
readonly objects = new Map<string, StoredImageAttachment>()
|
||||
beforeValidate: (() => Promise<void>) | undefined
|
||||
beforeRead: (() => Promise<void>) | undefined
|
||||
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
await this.beforeValidate?.()
|
||||
if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
}
|
||||
|
||||
saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
this.saved.push(input)
|
||||
const digest = createHash('sha256').update(input.data).digest('hex')
|
||||
const ref: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${digest}`),
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) })
|
||||
return Promise.resolve(ref)
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
await this.beforeRead?.()
|
||||
const stored = this.objects.get(ref.attachmentId)
|
||||
if (stored === undefined) throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
|
||||
return { ref: stored.ref, data: Uint8Array.from(stored.data) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted text response ending in a clean stop. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
@@ -93,6 +156,7 @@ export interface BridgeHarness {
|
||||
ctx: Context
|
||||
client: ClientSideConnection
|
||||
adapter: MockAdapter
|
||||
attachments: MemoryAttachmentStore | undefined
|
||||
updates: CapturedUpdate[]
|
||||
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
@@ -113,10 +177,13 @@ export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: AcpConfigOverrides
|
||||
persona?: string
|
||||
imageCapable?: boolean
|
||||
attachments?: boolean
|
||||
} = {}): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true)
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } })
|
||||
if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
@@ -135,6 +202,7 @@ export async function makeBridgeHarness(options: {
|
||||
const harness: BridgeHarness = {
|
||||
ctx,
|
||||
adapter,
|
||||
attachments: ctx.get('attachments') as MemoryAttachmentStore | undefined,
|
||||
updates,
|
||||
sessionUpdates,
|
||||
permissionRequests,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -41,35 +41,100 @@ describe('ACP prompt lifecycle', () => {
|
||||
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
|
||||
})
|
||||
|
||||
it('renders an assistant image as an explicit attachment placeholder', async () => {
|
||||
const attachmentId = `sha256:${'a'.repeat(64)}` as never
|
||||
harness = await makeBridgeHarness({
|
||||
script: [[
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'image',
|
||||
attachment: {
|
||||
attachmentId,
|
||||
mediaType: 'image/png',
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
},
|
||||
it('delivers a committed assistant image as verified ACP base64', async () => {
|
||||
const script: StreamChunk[][] = []
|
||||
harness = await makeBridgeHarness({ script })
|
||||
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' })
|
||||
script.push([
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: {
|
||||
type: 'image',
|
||||
attachment: ref,
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]],
|
||||
})
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
|
||||
await vi.waitFor(() => {
|
||||
expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`)
|
||||
expect(harness.updates).toContainEqual({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'image', data: 'AQ==', mimeType: 'image/png' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves committed text/image/text order on the ACP wire', async () => {
|
||||
const script: StreamChunk[][] = []
|
||||
harness = await makeBridgeHarness({ script })
|
||||
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' })
|
||||
script.push([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'before' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'image' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'image', attachment: ref } },
|
||||
{ type: 'block-start', index: 2, blockType: 'text' },
|
||||
{ type: 'block-end', index: 2, block: { type: 'text', text: 'after' } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
|
||||
|
||||
expect(harness.updates).toEqual([
|
||||
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before' } },
|
||||
{ sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' } },
|
||||
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not settle a prompt before ordered output delivery drains', async () => {
|
||||
const script: StreamChunk[][] = []
|
||||
harness = await makeBridgeHarness({ script })
|
||||
const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' })
|
||||
script.push([
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'image', attachment: ref } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const readStarted = Promise.withResolvers<undefined>()
|
||||
const delivery = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeRead = () => {
|
||||
readStarted.resolve(undefined)
|
||||
return delivery.promise
|
||||
}
|
||||
const sessionId = await newSession(harness)
|
||||
let settled = false
|
||||
|
||||
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
.finally(() => { settled = true })
|
||||
await readStarted.promise
|
||||
expect(settled).toBe(false)
|
||||
delivery.resolve(undefined)
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
})
|
||||
|
||||
it('fails prompt delivery when a committed image attachment is missing', async () => {
|
||||
const missing = {
|
||||
attachmentId: `sha256:${'a'.repeat(64)}` as never,
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
harness = await makeBridgeHarness({ script: [[
|
||||
{ type: 'block-start', index: 0, blockType: 'image' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'image', attachment: missing } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }))
|
||||
.rejects.toThrow(/assistant output delivery failed/)
|
||||
expect(harness.updates).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a failed turn and never publishes its partial chunks', async () => {
|
||||
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
@@ -194,6 +259,148 @@ describe('ACP prompt lifecycle', () => {
|
||||
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('reserves the prompt slot during image admission and cancels without a late followup', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
const sessionId = await newSession(harness)
|
||||
let settled = false
|
||||
const first = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
}).finally(() => { settled = true })
|
||||
await validationStarted.promise
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
await harness.client.cancel({ sessionId })
|
||||
expect(settled).toBe(false)
|
||||
releaseValidation.resolve(undefined)
|
||||
|
||||
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(harness.adapter.requests).toEqual([])
|
||||
const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? []
|
||||
expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not cancel unrelated Agent work while its prompt is still in admission', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: ['hang'] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await vi.waitFor(() => { expect(harness!.adapter.requests).toHaveLength(1) })
|
||||
|
||||
const prompt = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})
|
||||
await validationStarted.promise
|
||||
await harness.client.cancel({ sessionId })
|
||||
|
||||
expect(harness.adapter.requests[0]?.signal?.aborted).toBe(false)
|
||||
releaseValidation.resolve(undefined)
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(agent.status).toBe('running')
|
||||
agent.cancel({ kind: 'hook', reason: 'test cleanup' })
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('does not attribute an unrelated Agent failure during prompt admission', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('answer')] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
let failUnrelatedWork = true
|
||||
harness.ctx.on('agent/pre-step', (_payload, next) => {
|
||||
if (!failUnrelatedWork) return next()
|
||||
failUnrelatedWork = false
|
||||
throw new Error('unrelated pre-step failure')
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
const prompt = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})
|
||||
await validationStarted.promise
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'unrelated work' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
releaseValidation.resolve(undefined)
|
||||
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(messageText(harness)).toBe('answer')
|
||||
})
|
||||
|
||||
it('does not queue admitted content into an agent retired during storage', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
|
||||
const validationStarted = Promise.withResolvers<undefined>()
|
||||
const releaseValidation = Promise.withResolvers<undefined>()
|
||||
harness.attachments!.beforeValidate = () => {
|
||||
validationStarted.resolve(undefined)
|
||||
return releaseValidation.promise
|
||||
}
|
||||
const sessionId = await newSession(harness)
|
||||
const prompt = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})
|
||||
await validationStarted.promise
|
||||
|
||||
await harness.loopFiber.dispose()
|
||||
releaseValidation.resolve(undefined)
|
||||
|
||||
await expect(prompt).rejects.toThrow(/disposed outside the bridge/)
|
||||
expect(harness.attachments!.saved).toHaveLength(1)
|
||||
expect(harness.adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('honors cancellation in the admission-to-followup handoff gap', async () => {
|
||||
harness = await makeBridgeHarness({ imageCapable: true, script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
const saveImages = harness.attachments!.saveImages.bind(harness.attachments!)
|
||||
vi.spyOn(harness.attachments!, 'saveImages').mockImplementationOnce(async (inputs) => {
|
||||
const refs = await saveImages(inputs)
|
||||
queueMicrotask(() => { void harness!.client.cancel({ sessionId }) })
|
||||
return refs
|
||||
})
|
||||
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
|
||||
})).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(harness.adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('wraps an unexpected same-process followup failure and frees the prompt slot', async () => {
|
||||
harness = await makeBridgeHarness({ script: [] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
vi.spyOn(agent, 'followup').mockImplementationOnce(() => { throw new Error('synthetic followup failure') })
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/prompt was not queued: synthetic followup failure/)
|
||||
})
|
||||
|
||||
it('cancels a running turn and records the aborted outcome', async () => {
|
||||
harness = await makeBridgeHarness({ script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly and Host Agent/Session lookup policy",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
|
||||
README.md: f08568b4e12573418382c2b7138d2ecfeb598678
|
||||
README.zh.md: 94b523034438436175f7387df97fb51367e9da35
|
||||
README.md: 19232bd4bb86ed33e56fcdca93999967822422ab
|
||||
README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
`admitEncodedImages(attachments, images)` is the shared wire-batch admission used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64, the per-message count limit, and the aggregate byte limit from `imageLimits`, validates the whole batch, then commits every member and returns `ImageAttachmentRef`s in caller order; a rejected batch publishes no durable object. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
|
||||
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的批量准入函数:它按 `imageLimits` 强制执行规范 base64、单条消息张数上限与聚合字节上限,先校验整个批量,再提交每个成员并按调用方顺序返回 `ImageAttachmentRef`;被拒绝的批量不会发布任何持久化对象。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
|
||||
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Batch admission of base64-encoded image uploads. @module @deepseek-ai/dsh-attachment/admission */
|
||||
/** Wire-form admission of base64-encoded image uploads. @module @deepseek-ai/dsh-attachment/admission */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { AttachmentError } from './error.ts'
|
||||
@@ -15,42 +15,27 @@ function decodeBase64(data: string): Uint8Array {
|
||||
}
|
||||
|
||||
/** Store input for one decoded upload. */
|
||||
function saveInput(image: EncodedImageAttachment, data: Uint8Array): SaveImageAttachment {
|
||||
function saveInput(image: EncodedImageAttachment): SaveImageAttachment {
|
||||
return {
|
||||
data,
|
||||
data: decodeBase64(image.data),
|
||||
mediaType: image.mediaType,
|
||||
...image.name === undefined ? {} : { name: image.name },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one wire image batch against the per-message limits and durably
|
||||
* commit every member. The whole batch is validated before any member is
|
||||
* saved, so a rejected batch publishes no durable object.
|
||||
* @param attachments - the deployment attachment store enforcing per-image policy.
|
||||
* Admit one wire image batch: enforce canonical base64 on every member, then
|
||||
* delegate batch admission — count and aggregate-byte limits, media-type and
|
||||
* per-image validation, ordered commit — to {@link AttachmentStore.saveImages}.
|
||||
* The shared entry for every RPC endpoint accepting browser uploads.
|
||||
* @param attachments - the deployment attachment store owning batch policy.
|
||||
* @param images - base64-encoded uploads in caller order.
|
||||
* @returns durable references in the same order as `images`.
|
||||
* @throws AttachmentError on a non-canonical payload or an exceeded batch limit.
|
||||
* @throws AttachmentError on a non-canonical payload or a refused batch.
|
||||
*/
|
||||
export async function admitEncodedImages(
|
||||
attachments: AttachmentStore,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
): Promise<ImageAttachmentRef[]> {
|
||||
const limits = attachments.imageLimits
|
||||
if (images.length > limits.maxImagesPerMessage) {
|
||||
throw new AttachmentError('Upload exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const decoded = images.map(image => ({ image, data: decodeBase64(image.data) }))
|
||||
const totalBytes = decoded.reduce((sum, item) => sum + item.data.byteLength, 0)
|
||||
if (totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new AttachmentError('Upload exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
for (const item of decoded) {
|
||||
await attachments.validateImage(saveInput(item.image, item.data))
|
||||
}
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
for (const item of decoded) {
|
||||
refs.push(await attachments.saveImage(saveInput(item.image, item.data)))
|
||||
}
|
||||
return refs
|
||||
): Promise<readonly ImageAttachmentRef[]> {
|
||||
return attachments.saveImages(images.map(saveInput))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
|
||||
|
||||
const IMAGE_ADMISSION_ERROR_CODES = [
|
||||
'TOO_MANY_IMAGES',
|
||||
'IMAGES_TOO_LARGE',
|
||||
'UNSUPPORTED_IMAGE_TYPE',
|
||||
'INVALID_IMAGE_BASE64',
|
||||
'INVALID_IMAGE',
|
||||
'IMAGE_TYPE_MISMATCH',
|
||||
'IMAGE_TOO_LARGE',
|
||||
'IMAGE_TOO_MANY_PIXELS',
|
||||
] as const
|
||||
|
||||
/** Caller-correctable attachment failure codes raised while admitting image input. */
|
||||
export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number]
|
||||
|
||||
/** Stable attachment failure codes used for protocol error routing. */
|
||||
export type AttachmentErrorCode =
|
||||
| ImageAdmissionErrorCode
|
||||
| 'INVALID_ATTACHMENT_REF'
|
||||
| 'ATTACHMENT_CORRUPT'
|
||||
| 'ATTACHMENT_WRITE_FAILED'
|
||||
| 'ATTACHMENT_NOT_FOUND'
|
||||
| 'ATTACHMENT_READ_FAILED'
|
||||
|
||||
/** Runtime membership for structurally compatible errors crossing package boundaries. */
|
||||
const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet<string> = new Set(IMAGE_ADMISSION_ERROR_CODES)
|
||||
|
||||
/**
|
||||
* Stable failures suitable for host RPC error mapping.
|
||||
*
|
||||
@@ -11,16 +37,30 @@
|
||||
*/
|
||||
export class AttachmentError extends Error {
|
||||
/** Stable machine-routing failure code. */
|
||||
readonly code: string
|
||||
readonly code: AttachmentErrorCode
|
||||
|
||||
/**
|
||||
* @param message - human-readable failure description without raw bytes or host paths.
|
||||
* @param code - stable machine-routing code.
|
||||
* @param options - optional chained cause.
|
||||
*/
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'AttachmentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish caller-correctable image admission failures from storage faults.
|
||||
* @param error - failure raised while validating or persisting an image batch.
|
||||
* @returns whether the caller can correct the proposed image content or batch.
|
||||
*/
|
||||
export function isImageAdmissionError(
|
||||
error: unknown,
|
||||
): error is AttachmentError & { readonly code: ImageAdmissionErrorCode } {
|
||||
return error instanceof Error
|
||||
&& 'code' in error
|
||||
&& typeof error.code === 'string'
|
||||
&& IMAGE_ADMISSION_ERROR_CODE_SET.has(error.code)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { AttachmentError } from './error.ts'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
@@ -9,7 +10,8 @@ import type {
|
||||
} from './types.ts'
|
||||
|
||||
export { AttachmentId } from './brand.ts'
|
||||
export { AttachmentError } from './error.ts'
|
||||
export { AttachmentError, isImageAdmissionError } from './error.ts'
|
||||
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
|
||||
export { admitEncodedImages } from './admission.ts'
|
||||
export type {
|
||||
AttachmentId as AttachmentIdType,
|
||||
@@ -44,6 +46,35 @@ export abstract class AttachmentStore extends Service {
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate one ordered image batch before committing any member.
|
||||
* Validation failures start no writes; storage failures return no partial
|
||||
* references, although already published content-addressed objects may stay
|
||||
* unreachable until a future retention policy collects them.
|
||||
* @param inputs - encoded images in their owning message order.
|
||||
* @returns durable references in the exact input order.
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]> {
|
||||
const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits
|
||||
if (inputs.length > maxImagesPerMessage) {
|
||||
throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const totalBytes = inputs.reduce((sum, input) => sum + input.data.byteLength, 0)
|
||||
if (totalBytes > maxMessageImageBytes) {
|
||||
throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
for (const input of inputs) {
|
||||
if (!mediaTypes.includes(input.mediaType)) {
|
||||
throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE')
|
||||
}
|
||||
}
|
||||
for (const input of inputs) await this.validateImage(input)
|
||||
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
for (const input of inputs) refs.push(await this.saveImage(input))
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
|
||||
@@ -1,120 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import { admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
|
||||
/** One-pixel valid payloads are irrelevant here: the store below accepts any decoded bytes. */
|
||||
const PNG = 'AAAA' // canonical base64, 3 bytes
|
||||
|
||||
function refOf(input: SaveImageAttachment, ordinal: number): ImageAttachmentRef {
|
||||
return {
|
||||
attachmentId: `att-${ordinal}` as ImageAttachmentRef['attachmentId'],
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory store double recording call order; limits are per-test. */
|
||||
function storeOf(limits?: Partial<AttachmentStore['imageLimits']>) {
|
||||
const calls: string[] = []
|
||||
let saved = 0
|
||||
/** Delegation double: records the exact saveImages batch and answers ordered refs. */
|
||||
function storeOf() {
|
||||
const store = {
|
||||
imageLimits: {
|
||||
maxImageBytes: 1024,
|
||||
maxImagesPerMessage: 4,
|
||||
maxMessageImageBytes: 1024,
|
||||
maxImagePixels: 1_000_000,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
...limits,
|
||||
},
|
||||
validateImage: vi.fn((input: SaveImageAttachment) => {
|
||||
calls.push(`validate:${input.name ?? input.mediaType}`)
|
||||
return Promise.resolve()
|
||||
}),
|
||||
saveImage: vi.fn((input: SaveImageAttachment) => {
|
||||
calls.push(`save:${input.name ?? input.mediaType}`)
|
||||
saved += 1
|
||||
return Promise.resolve(refOf(input, saved))
|
||||
}),
|
||||
saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({
|
||||
attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'],
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
})))),
|
||||
}
|
||||
return { store: store as unknown as AttachmentStore, calls, mocks: store }
|
||||
return { store: store as unknown as AttachmentStore, mocks: store }
|
||||
}
|
||||
|
||||
describe('admitEncodedImages', () => {
|
||||
it('validates the whole batch before saving any member and returns refs in caller order', async () => {
|
||||
const { store, calls } = storeOf()
|
||||
it('decodes every member and delegates one ordered batch to saveImages', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
const refs = await admitEncodedImages(store, [
|
||||
{ mediaType: 'image/png', data: PNG, name: 'first.png' },
|
||||
{ mediaType: 'image/jpeg', data: PNG, name: 'second.jpg' },
|
||||
])
|
||||
expect(calls).toEqual(['validate:first.png', 'validate:second.jpg', 'save:first.png', 'save:second.jpg'])
|
||||
expect(refs.map(ref => ref.name)).toEqual(['first.png', 'second.jpg'])
|
||||
expect(mocks.saveImages).toHaveBeenCalledTimes(1)
|
||||
const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]
|
||||
expect(batch.map(input => [input.name, input.mediaType, input.data.byteLength]))
|
||||
.toEqual([['first.png', 'image/png', 3], ['second.jpg', 'image/jpeg', 3]])
|
||||
expect(refs.map(ref => ref.attachmentId)).toEqual(['att-1', 'att-2'])
|
||||
})
|
||||
|
||||
it('omits the name from store inputs when the upload has none', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
const refs = await admitEncodedImages(store, [{ mediaType: 'image/webp', data: PNG }])
|
||||
expect(mocks.saveImage).toHaveBeenCalledWith({ data: expect.any(Uint8Array) as unknown, mediaType: 'image/webp' })
|
||||
const batch = mocks.saveImages.mock.calls[0]?.[0] as readonly SaveImageAttachment[]
|
||||
expect('name' in (batch[0] as object)).toBe(false)
|
||||
expect(refs[0]?.name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('admits an empty batch without touching the store', async () => {
|
||||
it('delegates an empty batch unchanged', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
await expect(admitEncodedImages(store, [])).resolves.toEqual([])
|
||||
expect(mocks.validateImage).not.toHaveBeenCalled()
|
||||
expect(mocks.saveImage).not.toHaveBeenCalled()
|
||||
expect(mocks.saveImages).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('rejects a batch above the image-count limit before decoding', async () => {
|
||||
const { store, mocks } = storeOf({ maxImagesPerMessage: 1 })
|
||||
const batch = [
|
||||
{ mediaType: 'image/png' as const, data: PNG },
|
||||
{ mediaType: 'image/png' as const, data: 'not base64!!' },
|
||||
]
|
||||
await expect(admitEncodedImages(store, batch)).rejects.toMatchObject({
|
||||
name: 'AttachmentError',
|
||||
code: 'TOO_MANY_IMAGES',
|
||||
})
|
||||
expect(mocks.saveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a batch above the aggregate byte limit without saving', async () => {
|
||||
const { store, mocks } = storeOf({ maxMessageImageBytes: 5 })
|
||||
await expect(admitEncodedImages(store, [
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' })
|
||||
expect(mocks.saveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('admits a batch exactly at both limits', async () => {
|
||||
const { store } = storeOf({ maxImagesPerMessage: 2, maxMessageImageBytes: 6 })
|
||||
await expect(admitEncodedImages(store, [
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
])).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects non-canonical and empty base64 payloads', async () => {
|
||||
it('rejects non-canonical and empty base64 payloads before any store call', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
for (const data of ['', 'AAA', '!!!!']) {
|
||||
await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data }]))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE_BASE64' })
|
||||
.rejects.toMatchObject({ name: 'AttachmentError', code: 'INVALID_IMAGE_BASE64' })
|
||||
}
|
||||
expect(mocks.saveImage).not.toHaveBeenCalled()
|
||||
expect(mocks.saveImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates a store validation failure without saving any member', async () => {
|
||||
it('propagates the store batch rejection unchanged', async () => {
|
||||
const { store, mocks } = storeOf()
|
||||
mocks.validateImage.mockRejectedValueOnce(new AttachmentError('too many pixels', 'IMAGE_TOO_MANY_PIXELS'))
|
||||
await expect(admitEncodedImages(store, [
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
{ mediaType: 'image/png', data: PNG },
|
||||
])).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
|
||||
expect(mocks.saveImage).not.toHaveBeenCalled()
|
||||
const refused = Object.assign(new Error('Image batch exceeds the configured image-count limit.'), { code: 'TOO_MANY_IMAGES' })
|
||||
mocks.saveImages.mockRejectedValueOnce(refused)
|
||||
await expect(admitEncodedImages(store, [{ mediaType: 'image/png', data: PNG }])).rejects.toBe(refused)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AttachmentStore, {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
isImageAdmissionError,
|
||||
type ImageAttachmentRef,
|
||||
type ImageMediaType,
|
||||
type SaveImageAttachment,
|
||||
type StoredImageAttachment,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const LIMITS = {
|
||||
maxImageBytes: 4,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 5,
|
||||
maxImagePixels: 4,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
|
||||
class RecordingStore extends AttachmentStore {
|
||||
readonly imageLimits = LIMITS
|
||||
readonly calls: string[] = []
|
||||
rejectValidationAt: number | undefined
|
||||
rejectSaveAt: number | undefined
|
||||
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
const value = input.data[0] ?? 0
|
||||
this.calls.push(`validate:${value}`)
|
||||
if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`)
|
||||
}
|
||||
|
||||
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
const value = input.data[0] ?? 0
|
||||
this.calls.push(`save:${value}`)
|
||||
if (value === this.rejectSaveAt) throw new Error(`write:${value}`)
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`),
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
}
|
||||
}
|
||||
|
||||
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
|
||||
return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
|
||||
}
|
||||
|
||||
describe('AttachmentStore.saveImages', () => {
|
||||
it('validates the complete batch before saving in input order', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
|
||||
const refs = await store.saveImages([image(1), image(2)])
|
||||
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
|
||||
expect(refs.map(ref => ref.name)).toEqual(['1.png', '2.png'])
|
||||
})
|
||||
|
||||
it('rejects count, aggregate bytes, and deployment media types before validation', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
|
||||
await expect(store.saveImages([image(1), image(2), image(3)]))
|
||||
.rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' })
|
||||
await expect(store.saveImages([
|
||||
{ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' },
|
||||
{ data: Uint8Array.of(4, 5, 6), mediaType: 'image/png' },
|
||||
])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' })
|
||||
await expect(store.saveImages([image(1, 'image/jpeg')]))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_IMAGE_TYPE' })
|
||||
expect(store.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('starts no writes when any member fails validation', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
store.rejectValidationAt = 2
|
||||
|
||||
await expect(store.saveImages([image(1), image(2)]))
|
||||
.rejects.toThrow('invalid:2')
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2'])
|
||||
})
|
||||
|
||||
it('returns no partial references when storage fails after an earlier commit', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
store.rejectSaveAt = 2
|
||||
|
||||
await expect(store.saveImages([image(1), image(2)]))
|
||||
.rejects.toThrow('write:2')
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isImageAdmissionError', () => {
|
||||
it('separates caller-correctable image admission failures from storage faults', () => {
|
||||
expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true)
|
||||
expect(isImageAdmissionError(new AttachmentError('bad base64', 'INVALID_IMAGE_BASE64'))).toBe(true)
|
||||
expect(isImageAdmissionError(new AttachmentError('too many', 'TOO_MANY_IMAGES'))).toBe(true)
|
||||
expect(isImageAdmissionError(Object.assign(new Error('foreign policy error'), { code: 'IMAGE_TOO_LARGE' }))).toBe(true)
|
||||
expect(isImageAdmissionError(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))).toBe(false)
|
||||
expect(isImageAdmissionError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(false)
|
||||
expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-base",
|
||||
"description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root",
|
||||
"version": "0.1.0-rc.6",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user