From 3a4c436d83f7cfcb0ac6a4fcf4ebe457332a81fb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:18:57 +0800 Subject: [PATCH] refactor: narrow web image input v1 --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 18 +-- ...-image-input-and-durable-attachments.zh.md | 18 +-- ...-07-29-narrow-web-image-input-v1.i18n.yaml | 6 + .../2026-07-29-narrow-web-image-input-v1.md | 35 ++++++ ...2026-07-29-narrow-web-image-input-v1.zh.md | 35 ++++++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 114 ++---------------- apps/cli/tests/llm-route.spec.ts | 76 ------------ apps/web/tests/image-display.snapshot.ts | 34 ++++-- docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 10 +- .../core-data-structures/attachment.i18n.yaml | 4 +- docs/core-data-structures/attachment.md | 4 +- docs/core-data-structures/attachment.zh.md | 4 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/core.zh.md | 4 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../attachment/attachment-local/src/index.ts | 22 +--- .../attachment/attachment-local/src/store.ts | 9 -- .../attachment-local/tests/index.spec.ts | 23 ---- .../attachment-local/tests/store.spec.ts | 2 - .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 8 -- packages/attachment/attachment/src/types.ts | 2 - .../client/connection/src/client/fixture.ts | 9 -- packages/client/connection/src/index.ts | 2 +- .../client/connection/tests/node-half.spec.ts | 2 +- .../src/client/sessions/conversation.ts | 8 +- .../ui-conversation/src/client/apply.ts | 16 ++- .../src/client/chat/MessageImage.tsx | 7 +- .../src/client/chat/MessageItem.tsx | 9 +- .../ui-conversation/src/client/input/hub.ts | 7 +- .../ui-conversation/src/client/service.ts | 81 ++----------- .../tests/message-image.spec.tsx | 4 +- .../tests/service-orchestration.spec.ts | 19 +++ .../client/ui-trajectory/src/client/layout.ts | 1 - .../ui-trajectory/tests/client-bundle.spec.ts | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- packages/host/apiproxy/package.json | 1 - packages/host/apiproxy/src/api-proxy.ts | 59 ++++----- packages/host/apiproxy/src/api/host.schema.ts | 14 --- packages/host/apiproxy/src/api/host.ts | 3 - .../host/apiproxy/src/api/sessions.schema.ts | 2 +- packages/host/apiproxy/src/api/sessions.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 20 +++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 14 --- .../host/apiproxy/tests/rpc-schemas.spec.ts | 15 --- packages/host/apiproxy/tsconfig.json | 3 - packages/llm/llm-deepseek/src/adapter.ts | 3 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 12 +- packages/llm/llm-pi-ai/src/adapter.ts | 2 - packages/llm/llm-pi-ai/tests/adapter.spec.ts | 8 +- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 8 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 31 +---- packages/llm/llm/src/types.ts | 4 - packages/llm/llm/tests/service.spec.ts | 8 +- packages/llm/token-meter/src/index.ts | 11 -- .../llm/token-meter/tests/token-meter.spec.ts | 13 +- pnpm-lock.yaml | 3 - scripts/test-invariants.ts | 6 - 69 files changed, 280 insertions(+), 613 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md create mode 100644 .agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md delete mode 100644 apps/cli/tests/llm-route.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 2428e493fa..4a36c145fd 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -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: 7c14a8bdbb6d8164b2a6eb4432d196936271e4d1 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e9d7f8ebe78aa76285367e83374a6ace3a9eef21 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: ab1bd449f065346bc327984eacc85f71d4fd5a28 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: d51f9135352ba09b2cd2a083ade6b62f4a276216 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 7c14a8bdbb..ab1bd449f0 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -112,21 +112,21 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count — the complete batch, through the seam's storage-free `validateImage`, before persisting any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes. +Base64 crosses JSON-RPC once and is discarded after persistence. Version one accepts at most one image per prompt. The host validates canonical base64, individual bytes, magic-byte MIME, intrinsic dimensions, and intrinsic pixel count while durably saving that image. Only after the save succeeds does it call the agent with normalized text and a durable image block. A failure appends no user event and 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 invalidates late loads so an unmounted session cannot repopulate the cache. ### Model capabilities and provider behavior -Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. +Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the host-default active model and the image limits into `SessionsService`; the composer applies only the deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Decoded-pixel validation and the session's actual route remain authoritative on the host. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. `host.describe` projects the image limits into `SessionsService`; the composer applies only those deployment limits before allocating object URLs or base64. Model capability is deliberately not gated client-side: the handshake snapshot cannot represent a session's current target after `session.selectModel`, so the host preflight is the sole capability authority and its rejection renders through the composer error strip. Intrinsic-pixel validation and the session's actual route remain authoritative on the host. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches it through `dsh web --provider --model `: the yml pi-ai row already routes openai/anthropic with ambient credentials, and only a catalog provider absent from that row is mounted dynamically; the DeepSeek-only default remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, then resolves each durable reference and emits native image content only for models that declare image input. The shipped Web assembly reaches the statically configured openai/anthropic routes through `dsh web --provider --model ` with provider-native ambient credentials; other routes must be registered by the composition, and the default DeepSeek route remains text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. 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. -Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface 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 renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block. 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 `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. @@ -138,7 +138,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The client connection carrier independently caps buffered API request bodies, deriving the cap from the aggregate image limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts at most one PNG, JPEG, WebP, or GIF per prompt. SVG and remote URLs are excluded. Default deployment limits are 5 MiB and 40 million intrinsic pixels per image; they are validated backend configuration and projected to the client for fast-path guidance, while host validation remains authoritative. The client connection carrier independently caps buffered API request bodies from the single-image byte limit plus base64 and envelope expansion; a body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. @@ -148,7 +148,7 @@ Malformed base64, unsupported or mismatched media, truncated headers, excess byt | --- | --- | | `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | | `packages/attachment/attachment-local` | Private content-addressed storage, image-header validation, integrity verification, and configuration. | -| `packages/llm/llm` and `packages/llm/token-meter` | Role-neutral `ImageBlock`, modality metadata, and image cost estimation. | +| `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/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | @@ -161,7 +161,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, host-capability projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. +The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, image-limit projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -198,7 +198,7 @@ UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalog - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, and draft/session-scope/application object-URL cleanup; 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, nested tool-result images, preserved summary input, and explicit image-output rejection. - 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 declares text-only output; output-provider certification remains outside version one. +- The current production adapter set has no certified image-output route; output-provider certification remains outside version one. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index e9d7f8ebe7..d51f913535 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -112,21 +112,21 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和解码像素数——并在持久化任何成员之前,通过服务边界上不触碰存储的 `validateImage` 校验完整批次,因此一张畸形图片不会把批次中的有效成员留成无引用的孤儿对象。只有每张图片都成功后,宿主才会用规范化文本和持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。第一版每条提示词最多接受一张图片。宿主在持久保存该图片时,会校验规范 base64、单张图片字节数、与魔数字节一致的 MIME、固有尺寸和固有像素数。只有保存成功后,宿主才会用规范化文本和一个持久图片块调用 agent。失败时不追加用户事件,也不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并使延迟完成的加载失效,以免已卸载的会话重新写入缓存。 ### 模型能力与提供方行为 -模型目录项增加可选且可合并扩展的输入与输出模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 +模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用这些部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。固有像素校验与会话的实际路由仍由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径:yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model `,使用提供方原生环境凭据访问静态配置的 openai/anthropic 路由;其他路由必须由组合注册,而默认 DeepSeek 路由仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 -token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为文本计数。提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 +提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 压缩(compaction)会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 @@ -138,7 +138,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引;宿主校验仍是权威结果。客户端连接载体会独立限制 API 请求体的缓冲大小,并根据图片总量限制加上 base64 和请求封装的膨胀量推导上限;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版每条提示词最多接受一张 PNG、JPEG、WebP 或 GIF 图片。不接受 SVG 和远程 URL。默认部署限制为每张图片 5 MiB 和 4,000 万个固有像素;这些限制属于经过校验的后端配置,并会投影给客户端以提供快速路径指引,而宿主校验仍是权威结果。客户端连接载体根据单张图片字节上限加上 base64 和请求封装的膨胀量,独立限制 API 请求体的缓冲大小;未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的文件头、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 @@ -148,7 +148,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 | --- | --- | | `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | | `packages/attachment/attachment-local` | 私有内容寻址存储、图片头校验、完整性校验和配置。 | -| `packages/llm/llm` 和 `packages/llm/token-meter` | 角色无关的 `ImageBlock`、模态元数据和图片成本估算。 | +| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | | `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | @@ -161,7 +161,7 @@ token 估算根据图片尺寸计量,但不把 base64 或附件定位符作为 ### 实现 -已实现的范围包括附件服务边界、角色无关的图片块、图片感知的 token 估算、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、宿主能力投影、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 +已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、图片限制投影、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -198,7 +198,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序,以及草稿、会话作用域和应用层级的对象 URL 清理;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、嵌套工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 -- 当前生产适配器集合声明仅支持文本输出;输出提供方认证不在第一版范围内。 +- 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml new file mode 100644 index 0000000000..6255482e78 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.i18n.yaml @@ -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/simplification/2026-07-29-narrow-web-image-input-v1.md +2026-07-29-narrow-web-image-input-v1.md: 5a42c7acea778f8d1ff00e30bde0f849de12eb6b +2026-07-29-narrow-web-image-input-v1.zh.md: 0459074d0145c0d43008d32196ac6577eca958f3 diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md new file mode 100644 index 0000000000..5a42c7acea --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.md @@ -0,0 +1,35 @@ +# Agent Note: Narrow Web image input version one + +Status: implemented + +English | [中文](2026-07-29-narrow-web-image-input-v1.zh.md) + +## Problem + +The first durable Web image-input slice also introduced speculative surfaces for multiple-image transactions, arbitrary CLI provider mounting, output-modality discovery, alternative text, and provider-neutral visual token pricing. None was required to paste or drop one image, persist it before its message event, replay it through a visual adapter, or render it from authorized history. Keeping those surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. + +## Decision + +Version one accepts at most one image in a submitted prompt. The client gives immediate feedback, the host enforces the invariant, and the attachment seam validates and commits that one object. Deployment configuration retains per-image byte and pixel limits; request buffering derives from the per-image byte limit. There is no batch prevalidation, aggregate byte limit, image-count setting, transaction layer, or rollback protocol. + +The CLI only patches the selected provider and model. The boot composition must already register the route, as it does for the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not inspect the yml provider roster or dynamically mount an adapter. + +Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. + +The attachment seam exposes its limits plus `saveImage` and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. + +## Alternatives considered + +**Keep multiple images and add transaction or rollback machinery.** Without garbage collection, a partially persisted batch needs an ownership or reclamation design. One image satisfies the initial user path without creating that lifecycle. + +**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, batch validation, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. + +**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. + +**Mount any CLI-selected provider dynamically.** Configuration already owns plugin composition and credentials. Making selection also mutate composition duplicates that responsibility and requires parsing the config tree outside the loader. + +## Consequences + +The initial feature has fewer public fields, lifecycle operations, configuration knobs, and route-assembly branches. A prompt needing multiple images is rejected and must wait for an explicit multi-image persistence design. A provider absent from the composition cannot be selected solely with CLI flags. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. + +Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md new file mode 100644 index 0000000000..0459074d01 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-narrow-web-image-input-v1.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 收窄 Web 图片输入第一版 + +Status: implemented + +[English](2026-07-29-narrow-web-image-input-v1.md) | 中文 + +## 问题 + +首个持久化 Web 图片输入切片还引入了多张图片事务、由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本以及与提供方无关的视觉 token 定价等推测性表面。要实现粘贴或拖放一张图片、在其消息事件之前将其持久化、通过视觉适配器回放它,或从经授权的历史记录中渲染它,并不需要上述任何表面。保留这些表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 + +## 决策 + +第一版在每条提交的提示词中最多接受一张图片。客户端立即提供反馈,宿主强制执行该不变量,附件服务边界则校验并提交这一个对象。部署配置保留单张图片的字节和像素限制;请求缓冲上限由单张图片的字节限制派生。不设批量预校验、总字节限制、图片数量设置、事务层或回滚协议。 + +CLI 只修改选定的提供方和模型。启动组合必须已经注册该路由,随项目交付的 DeepSeek、OpenAI 和 Anthropic 路由正是如此;CLI 不会检查 yml 提供方清单,也不会动态挂载适配器。 + +确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 + +附件服务边界公开其限制以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 + +## 曾考虑的替代方案 + +**保留多张图片,并添加事务或回滚机制。** 没有垃圾回收时,部分持久化的批次需要一套所有权或回收设计。一张图片即可满足首个用户路径,而不引入该生命周期。 + +**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本、批量校验和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 + +**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 + +**动态挂载 CLI 选择的任意提供方。** 配置已经负责插件组合和凭据。让选择操作同时改变组合会造成职责重复,并要求在加载器之外解析配置树。 + +## 后果 + +初始功能具有更少的公开字段、生命周期操作、配置项和路由组装分支。需要多张图片的提示词会被拒绝,必须等待明确的多张图片持久化设计。未加入组合的提供方无法仅凭 CLI 标志选择。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 + +重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index b9f1b0d40c..5d2dafbd28 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 337370f6b3738cef22bc826501633b9aca1f8c62 -README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711 +README.md: 467c525361d003fb1638f5812a6524eaae84b7a8 +README.zh.md: 579cafd04e67aba42ad7d0ffa2105d33554fe1cb diff --git a/apps/cli/README.md b/apps/cli/README.md index 337370f6b3..467c525361 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -14,7 +14,7 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider --model ` selects that route: for the shipped roster (openai/anthropic) the already-mounted yml pi-ai row serves it with provider-native ambient credentials, while a pi-ai catalog provider absent from that row is mounted dynamically; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider --model ` selects a route already registered by that composition; the shipped yml pi-ai row provides openai/anthropic with provider-native ambient credentials, while the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index e1bdad1745..579cafd04e 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -14,7 +14,7 @@ TUI 界面: - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider --model ` 选择对应路由:出货清单内的 provider(openai/anthropic)由 yml 中已挂载的 pi-ai row 以提供方原生环境凭据直接服务,只有该 row 之外的 pi-ai catalog provider 才会动态挂载;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider --model ` 会选择该组合已经注册的路由;交付的 yml pi-ai row 使用提供方原生环境凭据提供 openai/anthropic 路由,而默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index bfea7f3662..55e5962e3a 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -61,89 +61,6 @@ export function resolveLanTrust( return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } -/** One provider/model source layer for {@link resolveLlmRoute}, in override order. */ -export interface LlmRouteInput { - /** CLI flag values (highest precedence). */ - cli: { provider?: string | undefined; model?: string | undefined } - /** Profile-json values (parsed JSON — validated here, the config boundary). */ - profile: { provider?: unknown; model?: unknown } - /** The api-gateway yml row's config values (deployment defaults). */ - gateway: { provider?: unknown; model?: unknown } - /** Providers the shipped yml already routes through its static pi-ai row. */ - ymlPiAiProviders: readonly string[] -} - -/** The boot's resolved LLM routing decision. */ -export interface LlmRoute { - /** Effective api-gateway provider. */ - provider: string - /** Pi-ai provider to mount dynamically; undefined when DeepSeek or a yml-routed provider serves the request. */ - dynamicPiAiProvider: string | undefined -} - -/** - * Resolve the boot's LLM route from the layered provider/model sources. - * A non-DeepSeek provider requires a model set at least as explicitly as the - * provider itself (flag/profile) — origin decides, never a comparison against - * any deployment's default model value, so editing the yml default cannot - * silently disarm the guard. Providers the shipped yml pi-ai row already - * routes are NOT mounted again: `LlmService.registerAdapter` rejects - * duplicate routes, so the gateway provider/model patch alone selects them. - * @param input - the layered provider/model sources and the yml pi-ai roster. - * @returns the effective provider and the dynamic pi-ai mount decision. - */ -export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { - const provider = input.cli.provider ?? input.profile.provider ?? input.gateway.provider - if (typeof provider !== 'string' || provider === '') { - throw new Error('dsh: api-gateway provider must be a non-empty string') - } - if (provider !== 'deepseek') { - const providerFromYml = input.cli.provider === undefined && input.profile.provider === undefined - // A yml-set provider trusts its own row pairing; an override must bring - // its model along instead of inheriting the yml default's. - const model = providerFromYml - ? input.gateway.model - : input.cli.model ?? input.profile.model - if (typeof model !== 'string' || model === '') { - throw new Error(`dsh: provider ${provider} requires an explicit model`) - } - } - return { - provider, - dynamicPiAiProvider: provider === 'deepseek' || input.ymlPiAiProviders.includes(provider) - ? undefined - : provider, - } -} - -/** - * Bypass parse of an include yml's top-level entry rows (id → row). Exported - * so tests can pin the shipped tree's real row coupling instead of literals. - * @param configPath - absolute path of the include cordis.yml. - * @returns row map keyed by entry id. - */ -export function parseIncludeYmlRows(configPath: string): Map { - const doc = yaml.load(readFileSync(configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${configPath} is not a top-level entry list`) - const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) - } - return rows -} - -/** - * Providers the yml's static pi-ai row routes — the roster {@link resolveLlmRoute} reuses. - * @param rows - parsed include rows. - * @returns provider ids in row order (empty when the row is absent). - */ -export function ymlPiAiProvidersOf(rows: ReadonlyMap): string[] { - const config = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | undefined - return (config?.providers ?? []) - .map(entry => entry.provider) - .filter((value): value is string => typeof value === 'string') -} - /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -198,11 +115,7 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string - /** - * Host default provider override. Providers the shipped yml pi-ai row - * already routes are reused; only a provider absent from that row mounts - * pi-ai dynamically. - */ + /** Host default provider override; the shipped tree must register the route. */ provider?: string /** Host default model override. */ model?: string @@ -229,8 +142,6 @@ export class AppCLIEntry { lanAddresses: readonly string[] = [] private patches: PatchOptions[] = [] - private piAiProvider: string | undefined - constructor(private readonly options: AppCLIEntryOptions) {} /** @@ -293,15 +204,6 @@ export class AppCLIEntry { if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider) if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) - const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const route = resolveLlmRoute({ - cli: { provider: this.options.provider, model: this.options.model }, - profile: { provider: profile.provider, model: profile.model }, - gateway: { provider: gatewayConfig?.provider, model: gatewayConfig?.model }, - ymlPiAiProviders: ymlPiAiProvidersOf(rows), - }) - this.piAiProvider = route.dynamicPiAiProvider - // Source 2b: authorities for the /api browser-trust fence (rationale on // resolveLanTrust). const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host @@ -333,12 +235,6 @@ export class AppCLIEntry { ...this.patches.length > 0 ? { patches: this.patches } : {}, }, }) - if (this.piAiProvider !== undefined) { - await ctx.loader.create({ - name: '@deepseek-ai/dsh-llm-pi-ai', - config: { providers: [{ provider: this.piAiProvider }] }, - }) - } if (this.options.dev) { await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' }) } @@ -373,7 +269,13 @@ export class AppCLIEntry { /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { - return parseIncludeYmlRows(this.options.configPath) + const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) + const rows = new Map() + for (const row of doc as { id?: string; config?: unknown }[]) { + if (typeof row.id === 'string') rows.set(row.id, row) + } + return rows } /** Profile json under cwd; read-only — never created here, absent = no user config. */ diff --git a/apps/cli/tests/llm-route.spec.ts b/apps/cli/tests/llm-route.spec.ts deleted file mode 100644 index 58edb47973..0000000000 --- a/apps/cli/tests/llm-route.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { parseIncludeYmlRows, resolveLlmRoute, ymlPiAiProvidersOf } from '../src/app-cli-entry.ts' - -/** The shipped yml shape: DeepSeek gateway default plus a pi-ai row routing openai/anthropic. */ -const SHIPPED = { - gateway: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - ymlPiAiProviders: ['openai', 'anthropic'], -} - -describe('resolveLlmRoute', () => { - it('keeps the DeepSeek default without any dynamic mount', () => { - expect(resolveLlmRoute({ cli: {}, profile: {}, ...SHIPPED })) - .toEqual({ provider: 'deepseek', dynamicPiAiProvider: undefined }) - }) - - it('reuses the yml pi-ai row for providers it already routes (no duplicate adapter)', () => { - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('mounts pi-ai dynamically only for providers absent from the yml row', () => { - expect(resolveLlmRoute({ - cli: { provider: 'google', model: 'gemini-3-pro' }, profile: {}, ...SHIPPED, - })).toEqual({ provider: 'google', dynamicPiAiProvider: 'google' }) - }) - - it('requires an explicit model wherever the provider override came from, by origin', () => { - // CLI provider with no CLI/profile model: the yml DeepSeek default must not leak in. - expect(() => resolveLlmRoute({ cli: { provider: 'anthropic' }, profile: {}, ...SHIPPED })) - .toThrow(/provider anthropic requires an explicit model/) - // Profile provider paired with a profile model is explicit enough. - expect(resolveLlmRoute({ - cli: {}, profile: { provider: 'openai', model: 'gpt-5' }, ...SHIPPED, - })).toEqual({ provider: 'openai', dynamicPiAiProvider: undefined }) - // Profile provider with only the yml default model: same gap, same refusal. - expect(() => resolveLlmRoute({ cli: {}, profile: { provider: 'openai' }, ...SHIPPED })) - .toThrow(/provider openai requires an explicit model/) - }) - - it('trusts a yml-set non-DeepSeek provider only when its own row carries the model', () => { - expect(resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic', model: 'claude-opus-4-8' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - expect(() => resolveLlmRoute({ - cli: {}, profile: {}, - gateway: { provider: 'anthropic' }, - ymlPiAiProviders: ['openai', 'anthropic'], - })).toThrow(/provider anthropic requires an explicit model/) - }) - - it('reuses the SHIPPED cordis.yml roster — the coupling that prevents the duplicate-adapter boot failure', () => { - // Parsed from the real file through the production extraction, not a - // literal roster: renaming the `llm-pi-ai` row or its providers field - // must fail here, because composePatches reads exactly these shapes. - const rows = parseIncludeYmlRows(join(import.meta.dirname, '..', 'cordis.yml')) - const roster = ymlPiAiProvidersOf(rows) - expect(roster).toEqual(['openai', 'anthropic']) - const gateway = (rows.get('api-gateway')?.config ?? {}) as { provider?: unknown; model?: unknown } - expect(resolveLlmRoute({ - cli: { provider: 'anthropic', model: 'claude-opus-4-8' }, profile: {}, - gateway, ymlPiAiProviders: roster, - })).toEqual({ provider: 'anthropic', dynamicPiAiProvider: undefined }) - }) - - it('fails loud on a missing or empty provider', () => { - expect(() => resolveLlmRoute({ cli: {}, profile: {}, gateway: {}, ymlPiAiProviders: [] })) - .toThrow(/provider must be a non-empty string/) - expect(() => resolveLlmRoute({ cli: { provider: '' }, profile: {}, ...SHIPPED })) - .toThrow(/provider must be a non-empty string/) - }) -}) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 40501dced1..1c35a0b818 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -5,7 +5,8 @@ // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the double-click ImageLightbox, and the composer -// intake chain (paste → thumbnail rail → image-only send enablement → remove). +// intake chain (paste → thumbnail rail → one-image limit → image-only send +// enablement → remove). import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -75,9 +76,9 @@ afterEach(() => { vi.unstubAllGlobals() }) -/** Boot the complete built client graph against the populated fixture branch. */ -function boot(): void { - history.replaceState(null, '', '/?fixture') +/** Boot the complete built client graph against one fixture branch. */ +function boot(search = '?fixture'): void { + history.replaceState(null, '', `/${search}`) const root = document.createElement('div') root.id = 'root' document.body.appendChild(root) @@ -162,12 +163,21 @@ it('renders the history image pair through the authorized attachment route and o }) it('accepts a pasted image into the composer rail and removes it', async () => { - boot() - await openFixtureSession() + boot('?fixture=empty') + + await screen.findByPlaceholderText('Choose a workspace to start', {}, { timeout: 10_000 }) + fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' }) + .find(el => el.getAttribute('aria-haspopup') === 'menu')!) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) + const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) + fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { + target: { value: 'image-input' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' })) // Image-only send arming is pinned at package level (input-bar.spec.tsx); // this assembled lane pins the intake chain over the built graph. - const textarea = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 }) + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -194,6 +204,16 @@ it('accepts a pasted image into the composer rail and removes it', async () => { ] `) + const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }], + getData: () => '', + }, + }) + expect(await screen.findByText('每条消息最多添加 1 张图片')).toBeTruthy() + expect(rail.querySelectorAll('img')).toHaveLength(1) + const remove = rail.querySelector('button[aria-label^="移除图片"]') if (remove === null) throw new Error('remove button missing') fireEvent.click(remove) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 52d75c9c80..0b91f8b9e8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -196,16 +196,12 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ - maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ - maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:26`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:20`](../packages/attachment/attachment-local/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4230d9b7db..d76d6c3cb2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -260,14 +260,6 @@ Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-ap Immutable binary attachment service. Implementations validate bytes before publishing a reference. ```ts cordis-catalog -/** - * Validate one image against the deployment policy without persisting anything. - * Callers persisting a multi-image batch validate every member first so a - * malformed member cannot leave earlier members as unreferenced objects. - * @param input - encoded bytes, declared media type, and optional display name. - */ -abstract validateImage(input: SaveImageAttachment): void - /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. @@ -2076,7 +2068,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:87`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/core-data-structures/attachment.i18n.yaml b/docs/core-data-structures/attachment.i18n.yaml index 145d21a267..a67bd99e1b 100644 --- a/docs/core-data-structures/attachment.i18n.yaml +++ b/docs/core-data-structures/attachment.i18n.yaml @@ -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/core-data-structures/attachment.md -attachment.md: c4c974b075300025868e90f9905193d62d68c4ee -attachment.zh.md: 12802a414018d081460459ad96ee4aa91801da62 +attachment.md: a06142b718dfcdc24ab037987a771869d68b31fa +attachment.zh.md: d41a3bf9cd49c7abb684c2476a1ade6e2d373732 diff --git a/docs/core-data-structures/attachment.md b/docs/core-data-structures/attachment.md index c4c974b075..a06142b718 100644 --- a/docs/core-data-structures/attachment.md +++ b/docs/core-data-structures/attachment.md @@ -39,8 +39,6 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number - maxImagesPerMessage: number - maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -69,4 +67,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks (magic bytes, declared media type, size and pixel limits) without persisting anything — batch callers MUST validate every member through it before persisting any, so a rejected batch leaves no partial objects behind. `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. `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. diff --git a/docs/core-data-structures/attachment.zh.md b/docs/core-data-structures/attachment.zh.md index 12802a4140..d41a3bf9cd 100644 --- a/docs/core-data-structures/attachment.zh.md +++ b/docs/core-data-structures/attachment.zh.md @@ -39,8 +39,6 @@ interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ interface ImageAttachmentLimits { maxImageBytes: number - maxImagesPerMessage: number - maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } @@ -69,4 +67,4 @@ interface StoredImageAttachment { } ``` -`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行同一套准入检查(magic bytes、声明的媒体类型、大小与像素上限)但不落任何持久化——批量调用方必须先对每个成员通过它校验、再持久化任何一个,从而保证被拒绝的批次不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index f8d2369029..c4f6d8d99b 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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/core-data-structures/core.md -core.md: 3a630a1d65a9623ed685d27ebe09dca388fa3e7d -core.zh.md: b06c5a19b352b58a022b1c0b686569c361977ff5 +core.md: e68c5de7430cceede68b76d3ac322872105b0a1f +core.zh.md: 030f0fb26407dcfb1a451493d27196b234bbf202 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 3a630a1d65..e68c5de743 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -116,7 +116,7 @@ interface ContentBlockMap { } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md) plus optional alternative text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) @@ -206,8 +206,6 @@ interface LlmModelInfo { description?: string /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ inputModalities?: readonly ModelModality[] - /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ - outputModalities?: readonly ModelModality[] } ``` diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index b06c5a19b3..030f0fb264 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -122,7 +122,7 @@ interface ContentBlockMap { } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图像附件](attachment.md),外加可选的替代文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图像附件](attachment.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) @@ -212,8 +212,6 @@ interface LlmModelInfo { description?: string /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ inputModalities?: readonly ModelModality[] - /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ - outputModalities?: readonly ModelModality[] } ``` diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 25edc0a814..1bce1f6651 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n alt?: string;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 18ce810f7b..65bfd8a34b 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -6,19 +6,13 @@ import z from 'schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { readImageFile, saveImageFile, validateImageFile } from './store.ts' +import { readImageFile, saveImageFile } from './store.ts' export { detectImage } from './image.ts' -export { readImageFile, saveImageFile, validateImageFile } from './store.ts' -export { AttachmentError } from '@deepseek-ai/dsh-attachment' -export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +export { readImageFile, saveImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 -/** Default maximum images in one prompt. */ -export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 -/** Default maximum aggregate image bytes in one prompt. */ -export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 @@ -28,10 +22,6 @@ export interface Config { dshHome?: string /** Maximum encoded bytes accepted for one image. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ - maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ - maxMessageImageBytes?: number /** Maximum intrinsic width multiplied by height accepted for one image. */ maxImagePixels?: number } @@ -41,8 +31,6 @@ export class LocalAttachmentStore extends AttachmentStore { static Config: z = z.object({ dshHome: z.string(), maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), - maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), - maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), }) @@ -55,17 +43,11 @@ export class LocalAttachmentStore extends AttachmentStore { this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) this.imageLimits = Object.freeze({ maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, - maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, - maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) } - validateImage(input: SaveImageAttachment): void { - validateImageFile(input, this.imageLimits) - } - async saveImage(input: SaveImageAttachment): Promise { return saveImageFile(this.root, input, this.imageLimits) } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index b8a5c6aa3c..18694b1642 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -52,15 +52,6 @@ function validateAdmission(metadata: Omit { @@ -16,8 +13,6 @@ describe('local attachment service', () => { const service = new LocalAttachmentStore(new Context(), {}) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, - maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, - maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) @@ -37,22 +32,4 @@ describe('local attachment service', () => { await rm(dshHome, { recursive: true, force: true }) } }) - - it('validates without persisting: a rejected image leaves no storage root behind', async () => { - const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) - try { - const service = new LocalAttachmentStore(new Context(), { dshHome }) - expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) }) - .toThrow(/Unsupported or malformed image data/) - const valid = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64', - )) - expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow() - // Validation is storage-free: nothing below the root may exist yet. - expect(existsSync(service.root)).toBe(false) - } finally { - await rm(dshHome, { recursive: true, force: true }) - } - }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 9287c702ad..43ba76fda4 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -28,8 +28,6 @@ const PNG = Uint8Array.from(Buffer.from( const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, - maxImagesPerMessage: 2, - maxMessageImageBytes: 2048, maxImagePixels: 16, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], } diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 0c170fea42..3e5a4a2e32 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -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: b8cee33b21caf5851f5c571ee7385fbd1b81dc97 -README.zh.md: 53107bd0f57b73043073295a9698df927e457365 +README.md: b25ef7bcf89b3b85600ed02ab12eb0cdd1117244 +README.zh.md: f45933c2a011978b31a306a1de80d7f031838c8e diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index b8cee33b21..b25ef7bcf8 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ 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. -Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so one malformed member cannot strand earlier members as unreferenced objects (there is no garbage collection). `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `saveImage` validates and commits one image at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 53107bd0f5..f45933c2a0 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。只有在提交消息或提交结构化提供方输出时,才会调用 `saveImage`,并且必须先于任何模型可见的会话事件发布。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象(系统不提供垃圾回收)。`readImage` 根据已记录的元数据校验内容寻址对象。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`saveImage` 在提交消息或提交结构化提供方输出时校验并提交一张图片,且发生在发布任何模型可见的会话事件之前。`readImage` 根据已记录的元数据校验内容寻址对象。 ## 模型体验 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8ddf7dd1d1..000856be78 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -33,14 +33,6 @@ export abstract class AttachmentStore extends Service { /** Deployment-resolved image policy used by authoritative and fast-path validation. */ abstract readonly imageLimits: ImageAttachmentLimits - /** - * Validate one image against the deployment policy without persisting anything. - * Callers persisting a multi-image batch validate every member first so a - * malformed member cannot leave earlier members as unreferenced objects. - * @param input - encoded bytes, declared media type, and optional display name. - */ - abstract validateImage(input: SaveImageAttachment): void - /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 88b1dceb52..754a9db072 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -36,8 +36,6 @@ export interface ImageAttachmentRef { /** Deployment-resolved limits shared by upload consumers and UI preflight. */ export interface ImageAttachmentLimits { maxImageBytes: number - maxImagesPerMessage: number - maxMessageImageBytes: number maxImagePixels: number mediaTypes: readonly ImageMediaType[] } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 70c573b587..20abb7f53f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1260,17 +1260,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { cwd: '/tmp/fixture', provider: 'fixture', model: 'fx-vision', - activeModel: { - provider: 'fixture', - id: 'fx-vision', - name: 'Fixture Vision', - inputModalities: ['text', 'image'], - outputModalities: ['text', 'image'], - }, imageLimits: { maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 10, - maxMessageImageBytes: 20 * 1024 * 1024, maxImagePixels: 40_000_000, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a26b98f779..05c4398763 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -52,7 +52,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const maxRequestBodyBytes = Math.ceil( - ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3, + ctx.attachments.imageLimits.maxImageBytes * 4 / 3, ) + REQUEST_ENVELOPE_HEADROOM_BYTES const route: WebRoute = { kind: 'prefix', diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3e64ef8907..7e2277305e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -23,7 +23,7 @@ function fakeHttpServer(routes: WebRoute[]): Pick i.kind === 'approval') ?? null @@ -157,8 +164,7 @@ export function apply(ctx: Context): void { children: { 'conversation.view': { kind: 'list', scope: 'session' } }, store: chatStore, inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => { - const conversation = ctx.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') + const conversation = concreteConversation(ctx) return { views: { list: viewTabs, @@ -186,8 +192,7 @@ export function apply(ctx: Context): void { 'conversation.input.model': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId): ComposerBarInjected => { - const conversation = ctx.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') + const conversation = concreteConversation(ctx) const shell = inputHub.shell(sessionId) return { keyboard: shell, @@ -248,8 +253,7 @@ export function apply(ctx: Context): void { }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { - const conversation = ctx.get('conversation') - if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') + const conversation = concreteConversation(ctx) const scoped = scopedConversation(sessions, sessionId) return { openDetails: (target) => { diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx index b4dcd19afd..e3bba25b8f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -7,9 +7,8 @@ import css from './MessageImage.module.css' export type ImageLoader = (attachment: ImageAttachmentRef) => Promise /** Compact history renderer with retryable loading and double-click original preview. */ -export function MessageImage({ attachment, alt, load }: { +export function MessageImage({ attachment, load }: { attachment: ImageAttachmentRef - alt?: string load: ImageLoader }) { const [src, setSrc] = useState(null) @@ -34,7 +33,7 @@ export function MessageImage({ attachment, alt, load }: { return () => { live = false } }, [attachment, load]) - const label = alt ?? attachment.name ?? '图片' + const label = attachment.name ?? '图片' if (error) return return ( <> @@ -55,7 +54,7 @@ export function MessageImage({ attachment, alt, load }: { /** Wrapping image group shared by user and assistant history. */ export function ImageGallery({ images, load, align }: { - images: readonly { attachment: ImageAttachmentRef; alt?: string }[] + images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' }) { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 31084387df..1d5ee66a72 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -23,18 +23,17 @@ type UserImage = Extract function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment']; alt?: string }[] + images: { attachment: UserImage['attachment'] }[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment']; alt?: string }[] = [] + const images: { attachment: UserImage['attachment'] }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string } + const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - const image = b as UserImage - images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } }) + images.push({ attachment: (b as UserImage).attachment }) } else rest.push(block) } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 96a66880ae..e0568ce70f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -151,7 +151,12 @@ export class InputHub implements InputService { // see them — release the drafts here instead of resurrecting them onto // a dead instance where they would leak for the page lifetime. if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) + if (shell?.snapshot.imageIds.length === 0) { + shell.restoreImages(imageIds) + } else { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const id of imageIds) conversation?.releaseDraftImage(id) + } if (shell?.snapshot.draft === '') shell.setDraft(text) return } diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 94afce5a85..1259664b45 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -29,10 +29,9 @@ export interface IConversation { * Send a prompt into the caller scope's session. * @param text - prompt text, sent verbatim as one text block. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. * @returns completion; business failures reject (and land in promptError). */ - send(text: string, mode: 'queue' | 'steer', images?: readonly File[]): Promise + send(text: string, mode: 'queue' | 'steer'): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -43,57 +42,11 @@ export interface IConversation { * @returns completion of the page pull. */ loadOlder(): Promise - /** - * Create runtime-only draft attachments and preview URLs. - * @param files - browser-owned image files. - * @param current - images already present in the composer. - * @returns ordered descriptors for the input state. - */ - createDraftImages( - files: readonly File[], - current?: readonly ComposerAttachment[], - ): readonly ComposerAttachment[] - /** - * Resolve ordered draft ids to runtime-owned attachments. - * @param ids - ordered composer attachment ids. - * @returns attachments still available in this browser runtime. - */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] - /** - * Release one draft attachment and its preview URL. - * @param id - draft-local attachment id. - */ - releaseDraftImage(id: string): void - /** - * Resolve a session-authorized historical image to an object URL. - * @param sessionId - session whose durable log grants the read. - * @param attachment - durable image reference from that log. - * @returns browser URL for inline and original-size rendering. - */ - resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise - /** - * Release every historical image URL owned by one rendered session. - * @param sessionId - session whose rendered image scope is ending. - */ - releaseSessionImages(sessionId: SessionId): void } -/** Opaque wrapper keeps browser `File` internals outside persisted store state. */ -class BrowserDraftAttachment implements ComposerAttachment { - readonly kind = 'image' as const - readonly id: string - readonly previewUrl: string - readonly #file: File - - constructor(file: File) { - this.id = crypto.randomUUID() - this.previewUrl = URL.createObjectURL(file) - this.#file = file - } - - get file(): File { - return this.#file - } +/** Create one browser-only draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerAttachment { + return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file } } interface ImageUrlEntry { @@ -106,7 +59,7 @@ interface ImageUrlEntry { export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face, design §5.2). */ readonly input: InputService - private readonly draftAttachments = new Map() + private readonly draftAttachments = new Map() private readonly imageUrls = new Map() private readonly imageGenerations = new Map() private readonly createdImageUrls = new Set() @@ -135,11 +88,10 @@ export class ConversationService extends Service implements IConversation { * exists for caller choreography (the composer restores the draft on it). * @param text - prompt text, sent verbatim as one text block when non-empty. * @param mode - queue after the current turn, or steer into it. - * @param images - browser-owned temporary images promoted by the host during this call. */ - async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise { + async send(text: string, mode: 'queue' | 'steer'): Promise { const session = this.scopedSession('send') - await this.sendFiles(session, text, mode, images) + await this.sendFiles(session, text, mode, []) } /** @@ -190,7 +142,7 @@ export class ConversationService extends Service implements IConversation { ): readonly ComposerAttachment[] { this.validateImages(files, current) return files.map((file) => { - const attachment = new BrowserDraftAttachment(file) + const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) this.createdImageUrls.add(attachment.previewUrl) return attachment @@ -330,19 +282,12 @@ export class ConversationService extends Service implements IConversation { current: readonly ComposerAttachment[], ): void { if (files.length === 0 && current.length === 0) return - // Deployment-wide limits only. Model capability is deliberately NOT - // checked here: the handshake's activeModel is the host default, not the - // session's current target (session.selectModel never refreshes it), so a - // client-side modality gate refuses sessions the host would accept and - // vice versa. The host preflight on session.prompt is the authority; its - // rejection renders through the composer error strip. + // Model capability is checked only by the host against the session's + // current target; the client owns deployment limits and the one-image UI. const description = this.requireSessions().hostDescription() const limits = description?.imageLimits const all = [...current.map(attachment => attachment.file), ...files] - if (limits !== undefined && all.length > limits.maxImagesPerMessage) { - throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`) - } - let totalBytes = 0 + if (all.length > 1) throw new Error('每条消息最多添加 1 张图片') for (const file of all) { const mediaType = imageMediaType(file.type) if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) { @@ -351,10 +296,6 @@ export class ConversationService extends Service implements IConversation { if (limits !== undefined && file.size > limits.maxImageBytes) { throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`) } - totalBytes += file.size - } - if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) { - throw new Error('图片总大小超过单条消息限制') } } diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx index b6746ff2ed..c6f991e18d 100644 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -48,14 +48,14 @@ describe('MessageImage', () => { Promise.resolve('blob:middle')} />, ) - const image = await view.findByAltText('middle') + const image = await view.findByAltText('history.png') const before = view.getByText('before') const after = view.getByText('after') expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index a73b119ac4..a9e1ce525a 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -29,6 +29,25 @@ async function bench() { } describe('ConversationService', () => { + it('keeps the browser draft to one image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })]) + if (first === undefined) throw new Error('draft attachment missing') + expect(() => b.root.createDraftImages( + [new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })], + [first], + )).toThrow('每条消息最多添加 1 张图片') + expect(created).toHaveBeenCalledOnce() + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index d6b55e5ba3..9abbe88420 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -644,7 +644,6 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { case 'image': return { type: 'image', content: stringifySourceValue(block.attachment), - ...(block.alt !== undefined ? { imageAlt: block.alt } : {}), } case 'other': return sourceBlock(block.block) } diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 129f756aa6..5a4af19657 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -60,7 +60,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'conversation', 'sessions']) + expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -72,10 +72,10 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and 'sessions' + // The plugin injects 'conversation' as an ordering edge and 'sessionHistory' // for its per-session history callback; this bench supplies both. ctx.provide('conversation', {}) - ctx.provide('sessions', {}) + ctx.provide('sessionHistory', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dc3b4bcf56..81dfe86af5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -160,10 +160,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'attachments', summary: 'Immutable binary attachment service.', methods: [ - { - signature: 'abstract validateImage(input: SaveImageAttachment): void', - jsDoc: '/**\n * Validate one image against the deployment policy without persisting anything.\n * Callers persisting a multi-image batch validate every member first so a\n * malformed member cannot leave earlier members as unreferenced objects.\n * @param input - encoded bytes, declared media type, and optional display name.\n */', - }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', @@ -1859,7 +1855,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ImageBlock', - declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n alt?: string;\n}', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n}', }, { name: 'ImageMediaType', @@ -1919,7 +1915,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n outputModalities?: readonly ModelModality[];\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmModelReasoningInfo', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 4b5fb30e92..006eade076 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -41,7 +41,6 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 053f5abf24..c9eae83342 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -11,8 +11,8 @@ import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, } from '@deepseek-ai/dsh-agent' -import { AttachmentError } from '@deepseek-ai/dsh-attachment-local' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' @@ -79,37 +79,23 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const limits = ctx.attachments.imageLimits - const prepared = content.map(part => part.type === 'text' - ? part - : { part, data: decodeBase64(part.data) }) - const images = prepared.filter((part): part is Extract => 'data' in part) - if (images.length > limits.maxImagesPerMessage) { - throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + if (content.filter(part => part.type === 'image').length > 1) { + throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES') } - const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) - if (totalBytes > limits.maxMessageImageBytes) { - throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } - // Validate the complete batch before persisting any member: the store has no - // garbage collection, so one malformed image must not leave the batch's - // valid members as published objects no message event will ever reference. - for (const image of images) { - ctx.attachments.validateImage({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }) - } - return Promise.all(prepared.map(async (item): Promise => { - if (!('data' in item)) return { type: 'text', text: item.text } + const durable: ContentBlock[] = [] + for (const part of content) { + if (part.type === 'text') { + durable.push({ type: 'text', text: part.text }) + continue + } const attachment = await ctx.attachments.saveImage({ - data: item.data, - mediaType: item.part.mediaType, - ...item.part.name === undefined ? {} : { name: item.part.name }, + data: decodeBase64(part.data), + mediaType: part.mediaType, + ...part.name === undefined ? {} : { name: part.name }, }) - return { type: 'image', attachment, ...item.part.alt === undefined ? {} : { alt: item.part.alt } } - })) + durable.push({ type: 'image', attachment }) + } + return durable } /** @@ -1222,8 +1208,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const target = targetFor(agent).current const provider = target.provider const model = target.model - const activeModel = await ctx.llm.resolveModelInfo(provider, model) - if (activeModel.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) { + const modelInfo = await ctx.llm.resolveModelInfo(provider, model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { return err(request, { code: 'attachment-error', message: `Model "${model}" does not support image input.`, @@ -1419,24 +1405,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, host: { - async describe(request) { - const activeModel = (await ctx.llm.listModels(defaults.provider)) - .find(model => model.id === defaults.model) + describe(request) { // TODO(step2): version should read apps/cli's package.json; placeholder for now. - return ok(request, { + return Promise.resolve(ok(request, { version: '0.0.1', // Same source as session.create's fallback: the UI's default project // must match where an unspecified-cwd session actually lands. cwd: defaults.cwd, provider: defaults.provider, model: defaults.model, - ...activeModel === undefined ? {} : { activeModel }, imageLimits: { ...ctx.attachments.imageLimits, mediaTypes: [...ctx.attachments.imageLimits.mediaTypes], }, attachedSessions: ctx.agents.list().length, - }) + })) }, async pickDirectory(request, signal) { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index e9120c9b44..e4ba100f17 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,15 +3,11 @@ */ import { z } from 'zod' -import type { ModelModality } from '@deepseek-ai/dsh-llm' import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import { imageMediaTypeSchema } from './sessions.schema.ts' -/** Merge-extensible modality passthrough: declaration merging cannot extend a runtime Zod union. */ -const modalitySchema = z.string() as unknown as z.ZodType - /** host.describe request payload (empty object literal). */ export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> @@ -21,18 +17,8 @@ export const hostDescribeValueSchema = z.object({ cwd: z.string(), provider: z.string().optional(), model: z.string().optional(), - activeModel: z.object({ - provider: z.string(), - id: z.string(), - name: z.string(), - description: z.string().optional(), - inputModalities: z.array(modalitySchema).optional(), - outputModalities: z.array(modalitySchema).optional(), - }).optional(), imageLimits: z.object({ maxImageBytes: z.number().int().positive(), - maxImagesPerMessage: z.number().int().positive(), - maxMessageImageBytes: z.number().int().positive(), maxImagePixels: z.number().int().positive(), mediaTypes: z.array(imageMediaTypeSchema), }).optional(), diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 1b53d6caa8..46e9c44c88 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,7 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import type { LlmModelInfo } from '@deepseek-ai/dsh-llm/types' /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -49,8 +48,6 @@ export interface HostApi { cwd: string provider?: string model?: string - /** Catalog entry for the active route; absent means its capabilities are unknown. */ - activeModel?: LlmModelInfo /** Resolved authoritative image-upload limits. */ imageLimits?: ImageAttachmentLimits attachedSessions: number diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index d501933615..c7cd2d02e0 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -210,7 +210,7 @@ export const imageMediaTypeSchema = z.union([ /** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ export const promptContentPartSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), text: z.string() }), - z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional(), alt: z.string().optional() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), ]) /** session.prompt request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 5579a89f94..ead239d247 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -162,7 +162,7 @@ export interface SessionSummary { /** Browser-submitted prompt content; image bytes are promoted to durable references by the host. */ export type PromptContentPart = | { type: 'text'; text: string } - | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string; alt?: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index db9d17b5b2..d265759da7 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -118,6 +118,26 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false } describe('Web session model selection', () => { + it('rejects a second prompt image before attachment persistence', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' } + const response = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [image, image], + })) + expect(response.result).toEqual({ + ok: false, + error: { + code: 'attachment-error', + message: 'A prompt may contain at most one image.', + details: { reason: 'TOO_MANY_IMAGES' }, + }, + }) + await ctx.fiber.dispose() + }) + it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek', diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 80a46af8cf..4295fe5b76 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -264,13 +264,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { hostDescription: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, })) @@ -281,13 +274,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => { value: { version: 'v', cwd: '/w', - activeModel: { - provider: 'future', - id: 'audio-model', - name: 'Audio Model', - inputModalities: ['text', 'audio'], - outputModalities: ['audio'], - }, attachedSessions: 0, }, }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index da3d9a89e5..ea6a25a6b0 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -249,25 +249,10 @@ describe('host domain schemas', () => { cwd: '/x', provider: 'p', model: 'm', - activeModel: { - provider: 'p', - id: 'm', - name: 'Model', - inputModalities: ['text', 'audio'], - outputModalities: ['text', 'audio'], - }, attachedSessions: 2, }) expect(value.attachedSessions).toBe(2) - expect(value.activeModel?.inputModalities).toEqual(['text', 'audio']) - expect(value.activeModel?.outputModalities).toEqual(['text', 'audio']) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', - cwd: '/x', - activeModel: { provider: 'p', id: 'm', name: 'Model', inputModalities: [{ type: 'audio' }] }, - attachedSessions: 0, - })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 6f32d1bbc6..d4da9a96a1 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../attachment/attachment" }, - { - "path": "../../attachment/attachment-local" - }, { "path": "../../llm/llm" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 8338becd7f..308da6dcb9 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -74,7 +74,6 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, inputModalities: ['text'], - outputModalities: ['text'], } } @@ -171,7 +170,7 @@ export class DeepSeekAdapter extends LlmAdapter { // capability — "unknown" here would let the host accept and persist // images the serializer must then reject. ...configured === undefined - ? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] } + ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, ...this.options.defaults?.thinking === 'disabled' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index dcf095bc2a..0dc969c17f 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -658,8 +658,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ @@ -762,8 +762,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) }) @@ -784,8 +784,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'], outputModalities: ['text'] }, - { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'], outputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 6f8a442e2b..2a4b08e607 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -130,7 +130,6 @@ export class PiAiAdapter extends LlmAdapter { id: model.id, name: model.name, inputModalities: [...model.input], - outputModalities: ['text'], }))) } @@ -155,7 +154,6 @@ export class PiAiAdapter extends LlmAdapter { id: model, name: resolvedModel.name, inputModalities: [...resolvedModel.input], - outputModalities: ['text'], context: { contextWindow: resolvedModel.contextWindow }, reasoning: { efforts: levels.map(level => ({ diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 6ffccfa0e1..1f51486584 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -250,16 +250,10 @@ describe('PiAiAdapter provider routing', () => { class LateAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('not used') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('not used')) } @@ -440,7 +434,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1') diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 0d1bdfadc5..db5b28bf9f 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -68,16 +68,10 @@ async function harness(image?: StoredImageAttachment): Promise { class E2eAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: fixture.data.byteLength, - maxImagesPerMessage: 1, - maxMessageImageBytes: fixture.data.byteLength, maxImagePixels: fixture.ref.width * fixture.ref.height, mediaTypes: [fixture.ref.mediaType], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('e2e attachment fixture is read-only') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('e2e attachment fixture is read-only')) } @@ -228,7 +222,7 @@ for (const profile of providerCases) { type: 'text', text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code', }, - { type: 'image', attachment: ref, alt: 'machine-readable symbol' }, + { type: 'image', attachment: ref }, ], source: { kind: 'plugin', plugin: 'test' }, })], diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 72747bbe5e..e617d3aa2d 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm/README.md -README.md: ecd6da304a894a687153608f5b468f867ad32e6b -README.zh.md: c43da45bf0c573a78c194d731bff1d9765b1eed6 +README.md: f6bf6ed88cc3aa21c57b5a08249848bdfff2f0c1 +README.zh.md: e466568de5a57b36dcc9fb0bd876ec2216a6c5f3 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ecd6da304a..f6bf6ed88c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. -Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. +Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`. Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index c43da45bf0..e466568de5 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -23,7 +23,7 @@ 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。 -确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 +确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速结束。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8fba36e3eb..d024db3c85 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -254,26 +254,9 @@ export class LlmService extends Service { return this.registration(provider).retryPolicy } - /** - * Validate adapter-owned modality arrays and detach them. One rule for the - * advisory catalog and exact resolution: both validate, both copy — two - * readings of the same adapter field with different trust or detachment - * would be an unexplained asymmetry. - * @param provider - provider route (diagnostic context). - * @param code - error code matching the calling surface. - * @param modalities - adapter-owned array, or undefined for unknown. - * @returns a detached copy, or undefined when absent. - */ - private detachedModalities( - provider: string, - code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO', - modalities: readonly unknown[] | undefined, - ): ModelModality[] | undefined { - if (modalities === undefined) return undefined - if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) { - throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code) - } - return [...(modalities as readonly ModelModality[])] + /** Detach typed adapter-owned modality metadata. */ + private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { + return modalities === undefined ? undefined : [...modalities] } /** @@ -300,15 +283,13 @@ export class LlmService extends Service { throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG') } seen.add(model.id) - const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities) + const inputModalities = this.detachedModalities(model.inputModalities) return { provider: model.provider, id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, } }) } @@ -360,15 +341,13 @@ export class LlmService extends Service { } // Capability metadata rides through: an explicit modality omission is // negative capability downstream preflights act on (image admission). - const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities) - const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities) + const inputModalities = this.detachedModalities(resolved.inputModalities) const info: LlmResolvedModelInfo = { provider, id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, ...inputModalities === undefined ? {} : { inputModalities }, - ...outputModalities === undefined ? {} : { outputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, } const reasoning = resolved.reasoning diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index ba03b51a8a..3d61f5b8a3 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -57,8 +57,6 @@ export interface ImageBlock { type: 'image' /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ attachment: ImageAttachmentRef - /** Optional provider- and UI-facing alternative text, carried from the prompt wire's image part. */ - alt?: string } /** A tool invocation requested by the model. */ @@ -156,8 +154,6 @@ export interface LlmModelInfo { description?: string /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ inputModalities?: readonly ModelModality[] - /** Structured response modalities; absent means unknown, while an explicit omission is negative capability. */ - outputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index e0d173599d..98ccb1befb 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -857,8 +857,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'model', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'model', name: '' }, 'empty name'], [{ provider: 'route', id: 'model', name: 'Model', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'model', name: 'Model', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'model', name: 'Model', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid exact model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) @@ -880,7 +878,7 @@ describe('LlmService', () => { override resolveModel(): Promise { return Promise.resolve({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) } }(SCRIPT) @@ -890,7 +888,7 @@ describe('LlmService', () => { // rebuild that drops it silently reads as "modalities unknown". await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({ provider: 'route', id: 'model', name: 'Model', - inputModalities: ['text', 'image'], outputModalities: ['text'], + inputModalities: ['text', 'image'], }) }) @@ -1177,8 +1175,6 @@ describe('LlmService', () => { [{ provider: 'route', id: 'm', name: 1 }, 'non-string name'], [{ provider: 'route', id: 'm', name: '' }, 'empty name'], [{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'], - [{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'], - [{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'], ] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index ec2698393e..533ebd2453 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -25,11 +25,6 @@ const CHARS_PER_TOKEN = 4 /** Per-block structural overhead for JSON framing and type tags. */ const BLOCK_OVERHEAD = 4 -/** Provider-neutral visual estimate: base cost plus one cost unit per 512px tile. */ -const IMAGE_BASE_TOKENS = 85 -const IMAGE_TILE_TOKENS = 170 -const IMAGE_TILE_EDGE = 512 - /** Role-field framing overhead added to every priced message. */ const ROLE_OVERHEAD = 4 @@ -362,12 +357,6 @@ export class TokenMeterService extends Service { case 'reasoning': tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD break - case 'image': { - const tiles = Math.ceil(block.attachment.width / IMAGE_TILE_EDGE) - * Math.ceil(block.attachment.height / IMAGE_TILE_EDGE) - tokens += IMAGE_BASE_TOKENS + tiles * IMAGE_TILE_TOKENS + BLOCK_OVERHEAD - break - } case 'tool-call': tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN) + Math.ceil(block.arguments.length / CHARS_PER_TOKEN) diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 4ad886f419..e658fe2981 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' @@ -129,16 +128,6 @@ describe('TokenMeterService pricing', () => { const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, - { - type: 'image', - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png', - bytes: 1, - width: 1024, - height: 513, - }, - }, { type: 'tool-call', id: CallId('c'), name: 'read', arguments: '{"x":1}' }, { type: 'tool-result', @@ -152,7 +141,7 @@ describe('TokenMeterService pricing', () => { role: 'assistant', content: blocks, source: { kind: 'plugin', plugin: 'test' }, })) - expect(estimated).toBe(813) + expect(estimated).toBeGreaterThan(30) expect(service.estimateMessage(textMessage('abcd'))).toBe(9) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc2e7da8af..a57fec0692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2965,9 +2965,6 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment - '@deepseek-ai/dsh-attachment-local': - specifier: workspace:^ - version: link:../../attachment/attachment-local '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index c843f94a5a..d388f31241 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -90,16 +90,10 @@ const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invari class TestAttachmentStore extends AttachmentStore { readonly imageLimits: ImageAttachmentLimits = { maxImageBytes: 1, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1, maxImagePixels: 1, mediaTypes: ['image/png'], } - validateImage(_input: SaveImageAttachment): void { - throw new Error('test invariant attachment store does not validate images') - } - saveImage(_input: SaveImageAttachment): Promise { return Promise.reject(new Error('test invariant attachment store does not save images')) }