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 c58ffcd573..2428e493fa 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: 2b676c1965dbef0bdc24ba6d5b4af5bf66840780 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: f13648051bcf4c81c8b032897645c9b6aeb0d32a +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 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 2b676c1965..7c14a8bdbb 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 @@ -122,7 +122,7 @@ Model catalog entries gain optional merge-extensible input and output modality d 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 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 `, which mounts that pi-ai catalog route with the provider's ambient credentials; 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 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. 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. 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 f13648051b..e9d7f8ebe7 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 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。`host.describe` 把宿主默认的当前模型和图片限制投影到 `SessionsService`;composer 在分配对象 URL 或 base64 前只应用部署级限制。模型能力刻意不在客户端把关:握手快照无法表达 `session.selectModel` 之后会话的当前目标,因此宿主前置检查是唯一的能力权威,其拒绝通过 composer 错误条呈现。解码像素校验与会话的实际路由仍由宿主作出权威判定。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径——该命令用提供方的环境凭据挂载对应的 pi-ai 目录路由;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,再解析每个持久引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的 Web 组装通过 `dsh web --provider --model ` 到达这条路径:yml 的 pi-ai row 已用环境凭据路由 openai/anthropic,只有该 row 之外的目录 provider 才会动态挂载;仅含 DeepSeek 的默认组装仍是纯文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 0d11f40393..b9f1b0d40c 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: 98b09f7da745d152e90a4ad84ee1639b20ab295b -README.zh.md: c8d632c63868dd1906c9bce05c88134ea98104a2 +README.md: 337370f6b3738cef22bc826501633b9aca1f8c62 +README.zh.md: e1bdad1745aed4f9f6cd08d9e9c43bcadcec7711 diff --git a/apps/cli/README.md b/apps/cli/README.md index 98b09f7da7..337370f6b3 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 ` mounts that pi-ai catalog route with provider-native ambient credentials; 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 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 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 c8d632c638..e1bdad1745 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 ` 会挂载对应的 pi-ai catalog 路由,并使用提供方原生的环境凭据;默认 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 ` 选择对应路由:出货清单内的 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`)。 已交付的 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 4bed5bbba7..bfea7f3662 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -116,6 +116,34 @@ export function resolveLlmRoute(input: LlmRouteInput): LlmRoute { } } +/** + * 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 @@ -170,7 +198,11 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string - /** Host default provider override. Non-DeepSeek routes mount pi-ai with ambient credentials. */ + /** + * 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. + */ provider?: string /** Host default model override. */ model?: string @@ -262,14 +294,11 @@ export class AppCLIEntry { if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model) const gatewayConfig = rows.get('api-gateway')?.config as Record | undefined - const piAiRow = rows.get('llm-pi-ai')?.config as { providers?: { provider?: unknown }[] } | 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: (piAiRow?.providers ?? []) - .map(p => p.provider) - .filter((value): value is string => typeof value === 'string'), + ymlPiAiProviders: ymlPiAiProvidersOf(rows), }) this.piAiProvider = route.dynamicPiAiProvider @@ -344,13 +373,7 @@ export class AppCLIEntry { /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ private parseYmlRows(): Map { - 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 + return parseIncludeYmlRows(this.options.configPath) } /** 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 index 7dfc8adf41..58edb47973 100644 --- a/apps/cli/tests/llm-route.spec.ts +++ b/apps/cli/tests/llm-route.spec.ts @@ -1,6 +1,7 @@ /** resolveLlmRoute: layered provider/model resolution and the dynamic pi-ai mount decision. */ +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { resolveLlmRoute } from '../src/app-cli-entry.ts' +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 = { @@ -52,6 +53,20 @@ describe('resolveLlmRoute', () => { })).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/) diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index b0d4125eac..40501dced1 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -120,24 +120,41 @@ it('renders the history image pair through the authorized attachment route and o await openFixtureSession() // Both the user-side (align=end) and assistant-side (align=start) galleries - // load real fixture bytes over sessions.attachment (data: fallback in jsdom). + // load real fixture bytes over sessions.attachment. jsdom provides + // createObjectURL, so this environment MUST take the object-URL path — a + // data: src here would mean the fallback ran where it should not. await waitFor(() => { - const user = document.querySelector('[data-align="end"] img') - const assistant = document.querySelector('[data-align="start"] img') - if (user === null || assistant === null) throw new Error('history image galleries missing') - // jsdom serves object URLs; environments without createObjectURL fall back to data:. - expect(user.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) - expect(assistant.getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + if (document.querySelector('[data-align="end"] img') === null + || document.querySelector('[data-align="start"] img') === null) { + throw new Error('history image galleries missing') + } }, { timeout: 10_000 }) + const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)] + .map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] })) + expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(` + { + "assistant": [ + { + "alt": "fixture-image.png", + "scheme": "blob", + }, + ], + "user": [ + { + "alt": "fixture-image.png", + "scheme": "blob", + }, + ], + } + `) const userImage = document.querySelector('[data-align="end"] img')! - expect(userImage.getAttribute('alt')).toBe('fixture-image.png') // Double-click opens the original-size lightbox; Escape/close dismisses it. const frame = userImage.closest('button') if (frame === null) throw new Error('image frame button missing') fireEvent.doubleClick(frame) const lightbox = await screen.findByRole('dialog') - expect(within(lightbox).getByRole('img').getAttribute('src')).toMatch(/^(blob:|data:image\/png;base64,)/) + expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() @@ -166,7 +183,16 @@ it('accepts a pasted image into the composer rail and removes it', async () => { if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) - expect(rail.querySelector('img')?.getAttribute('src')).toMatch(/^(blob:|data:)/) + expect([...rail.querySelectorAll('img')].map(img => ({ + alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0], + }))).toMatchInlineSnapshot(` + [ + { + "alt": "pasted.png", + "scheme": "blob", + }, + ] + `) const remove = rail.querySelector('button[aria-label^="移除图片"]') if (remove === null) throw new Error('remove button missing') diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..2995e5d0be 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -550,7 +550,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:58`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:59`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6c9b337e..cb2e79448d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -852,7 +852,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:192`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..45bb137e0d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index e64647bee2..b8a5c6aa3c 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -79,21 +79,28 @@ async function syncDirectory(path: string): Promise { } /** - * Create one private directory tree and persist every newly published ancestor. + * Create one private directory tree and persist every ancestor entry up to a + * caller-vouched durable boundary. The walk deliberately ignores what mkdir + * reports as newly created: a concurrent first save can create a level this + * process then merely observes, so "already existed" is not "already durable" + * — the entry may still be unsynced in the creator, and a crash would drop a + * directory the session checkpoint already references. Re-syncing a durable + * entry is harmless; skipping an unsynced one is not. * @param path - absolute directory to create. + * @param boundary - absolute ancestor the caller vouches is already durable. */ -async function ensureDurableDirectory(path: string): Promise { +async function ensureDurableDirectory(path: string, boundary: string): Promise { const target = resolve(path) - const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 }) + const stop = resolve(boundary) + await mkdir(target, { recursive: true, mode: 0o700 }) await chmod(target, 0o700) - if (firstCreated === undefined) return - - const highestCreated = resolve(firstCreated) - let created = target - while (true) { - await syncDirectory(dirname(created)) - if (created === highestCreated) return - created = dirname(created) + let level = target + while (level !== stop) { + const parent = dirname(level) + await syncDirectory(parent) + /* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */ + if (parent === level) return + level = parent } } @@ -110,8 +117,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const sha256 = digest(input.data) const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') - await ensureDurableDirectory(bucket) - await ensureDurableDirectory(staging) + // The durable boundary is the root's grandparent (DSH_HOME for the + // documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be + // first-created by a concurrent save, so their entries sync on every path. + const boundary = dirname(dirname(resolve(root))) + await ensureDurableDirectory(bucket, boundary) + await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) const target = objectPath(root, sha256) let handle diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 73ebb3bdec..9287c702ad 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -47,7 +47,7 @@ afterEach(async () => { }) describe('local attachment store', () => { - it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => { + it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => { const storageRoot = await root() const base = join(storageRoot, '..', '..') const sha256 = createHash('sha256').update(PNG).digest('hex') @@ -57,12 +57,20 @@ describe('local attachment store', () => { await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + // Every level between each created directory and the vouched boundary + // syncs unconditionally — "already existed" is not "already durable" + // when a concurrent first save may have created but not yet synced it. expect(fsControl.syncedDirectories).toEqual([ + // bucket chain: every parent entry between the bucket and the boundary. objects, storageRoot, join(storageRoot, '..'), base, + // staging chain re-walks the shared ancestors after creating tmp. storageRoot, + join(storageRoot, '..'), + base, + // publication: the settled object's bucket and its parent for the rename. bucket, objects, ]) diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 2e3d67f181..30dd2c0b7f 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -30,9 +30,13 @@ export async function bridge( }) const declaredLength = req.headers['content-length'] if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { - res.writeHead(413) + // Same discipline as the chunked-overrun path below: destroy, never + // drain. resume() would keep the socket open while the client trickles + // its declared length — an already-rejected request holding a server + // socket for as long as it likes. + res.writeHead(413, { connection: 'close' }) res.end() - req.resume() + req.destroy() return } const chunks: Buffer[] = [] diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index 608630cf15..8191d2b019 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest' import { bridge } from '../src/http-bridge.ts' describe('HTTP bridge abort', () => { + it('destroys a declared-oversize request instead of draining it', async () => { + const destroyed: true[] = [] + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session.prompt', + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '999999' }, + destroy: () => { destroyed.push(true) }, + }) + let status: number | undefined + let headers: unknown + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number, values?: unknown) { status = code; headers = values; return this }, + write() { return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + await bridge(request, response, { + fetch: () => { throw new Error('a rejected request must never reach the handler') }, + }, 1000) + // The socket must not stay parked draining a body the client can trickle + // at will after the rejection — same discipline as the chunked overrun. + expect(status).toBe(413) + expect(headers).toMatchObject({ connection: 'close' }) + expect(destroyed).toHaveLength(1) + }) + it('aborts a pending native picker request when the browser disconnects', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 66c2b5189d..96a66880ae 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -87,11 +87,17 @@ export class InputHub implements InputService { for (const off of offs) off() // Draft attachments die with the scope: the shell only holds ids, so // the service-owned File objects and object URLs must be released - // here or they leak for the page lifetime. + // here or they leak for the page lifetime. The lookup is optional — + // during application teardown or HMR of this plugin the root + // `conversation` service can already be unregistered while session + // scopes are still alive; a throwing disposer would abort teardown + // quiescence, and the service's own disposal effect revokes every + // remaining URL in that case anyway. const drafts = shell.snapshot.imageIds shell.dispose() this.shells.delete(id) - for (const imageId of drafts) this.conversation().releaseDraftImage(imageId) + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const imageId of drafts) conversation?.releaseDraftImage(imageId) } }, 'conversation.input: session shell') return shell @@ -139,8 +145,18 @@ export class InputHub implements InputService { // Commit, not an editable clear: undo must not resurrect sent content. shell?.commitSend(imageIds) void this.conversation().sendSession(session, text, mode, imageIds).catch(() => { - shell?.restoreImages(imageIds) - if (shell?.snapshot.draft === '') shell.setDraft(text) + // Restore only into the shell that still owns the session: if the scope + // died while the send was in flight, `commitSend` already removed the + // ids from the (now disposed) shell, so the teardown release could not + // 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.draft === '') shell.setDraft(text) + return + } + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const id of imageIds) conversation?.releaseDraftImage(id) }) } diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index afc79c91e6..a73b119ac4 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -69,6 +69,34 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('releases in-flight send images when the scope dies before the failure lands', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'b.png', { type: 'image/png' })]) + if (attachment === undefined) throw new Error('draft attachment missing') + const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId) + shell.addImages([attachment.id]) + let reject!: (error: Error) => void + b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never) + shell.setDraft('x') + shell.submit('queue') + // commitSend already removed the ids from the shell; kill the scope + // while the RPC is still pending, then land the failure. + await b.runtime.sessions.remove('s1') + reject(new Error('transport died')) + await vi.waitFor(() => { + expect(revoked).toHaveBeenCalledWith('blob:inflight-1') + }) + expect(b.root.draftImages([attachment.id])).toEqual([]) + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 617d252feb..78c2a75080 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -110,33 +110,48 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten })) } -function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined { +/** + * The ONE recursive block walk shared by attachment authorization and the + * model-selection gate (nested tool-result content included). Both consumers + * must agree on what counts as replayed image content — a route added to one + * walker but not the other would silently skip authorization or stranding + * protection — so there is exactly one walker, parameterized by match. + */ +function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { if (!Array.isArray(content)) return undefined for (const value of content) { if (typeof value !== 'object' || value === null || Array.isArray(value)) continue const block = value as { type?: unknown; attachment?: unknown; content?: unknown } if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { const ref = block.attachment as ImageAttachmentRef - if (String(ref.attachmentId) === attachmentId) return ref + if (match(ref)) return ref } if (block.type === 'tool-result') { - const nested = imageInContent(block.content, attachmentId) + const nested = imageBlockIn(block.content, match) if (nested !== undefined) return nested } } return undefined } +/** Every replayed content route of one event: direct content, wrapped message content, streamed block-end. */ +function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { + const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } } + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + if (data.message !== undefined) { + const wrapped = imageBlockIn(data.message.content, match) + if (wrapped !== undefined) return wrapped + } + if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { + return imageBlockIn([data.chunk.block], match) + } + return undefined +} + /** True when any block (nested tool-result content included) is an image block. */ function contentHasImage(content: unknown): boolean { - if (!Array.isArray(content)) return false - for (const value of content) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; content?: unknown } - if (block.type === 'image') return true - if (block.type === 'tool-result' && contentHasImage(block.content)) return true - } - return false + return imageBlockIn(content, () => true) !== undefined } /** @@ -145,23 +160,13 @@ function contentHasImage(content: unknown): boolean { * The log is immutable, so a true here is permanent for the session's life. */ function sessionHasImage(events: readonly SessionEvent[]): boolean { - return events.some((event) => { - const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } } - if (contentHasImage(data.content)) return true - if (data.message !== undefined && contentHasImage(data.message.content)) return true - return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' && contentHasImage([data.chunk.block]) - }) + return events.some(event => imageInEvent(event, () => true) !== undefined) } function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { for (const event of events) { - const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } } - const direct = imageInContent(data.content, attachmentId) - if (direct !== undefined) return direct - if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { - const streamed = imageInContent([data.chunk.block], attachmentId) - if (streamed !== undefined) return streamed - } + const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) + if (found !== undefined) return found } return undefined } @@ -1141,7 +1146,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // wire routes reject image content on text-only models — accepting // this selection would strand the session (every turn fails, no // in-product recovery). Refuse at the selection boundary instead. - if (sessionHasImage(found.agent.session.events)) { + // The pending inbox counts too: a queued image prompt enters the log + // only when claimed, which would happen AFTER this switch landed. + const queuedImage = (queuedMirror.get(sessionId) ?? []) + .some(entry => contentHasImage(entry.message.content)) + if (queuedImage || sessionHasImage(found.agent.session.events)) { const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { return err(request, { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 2830f8d84d..e9120c9b44 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -37,8 +37,6 @@ export const hostDescribeValueSchema = z.object({ mediaTypes: z.array(imageMediaTypeSchema), }).optional(), attachedSessions: z.number().int().nonnegative(), - // Open string, not a literal union: unknown kinds must survive the wire so - // a merge-added capability can advertise (the client hides the affordance). }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 1b6618becf..db9d17b5b2 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -274,6 +274,52 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => { + const { ctx, sessionId, agent } = await harness() + ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + }('Text Only', [])) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + // The queued message enters the session log only when claimed — after a + // model switch would already have landed. The pending-inbox mirror must + // therefore gate the switch too. + ctx.emit('agent/inbox/enqueue', agent, { + id: 'q-1', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }], + } as never, 'queued') + const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) + expect(stranded.result.ok).toBe(false) + // Claiming the message drains the mirror; the log now owns the decision. + ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued') + expect(expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + await ctx.fiber.dispose() + }) + + it('authorizes an attachment read referenced only from wrapped message content', async () => { + const { ctx, sessionId, agent } = await harness() + const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 } + ctx.provide('attachments', { + readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }), + } as never) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + // The only reference lives inside an assistant/message wrapper — the same + // walk that gates model selection must authorize the read, or a real host + // denies galleries the fixture (with its own authorization mirror) serves. + agent.session.append('assistant/message', { + turn: 1, step: 0, + message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] }, + } as never, { surfaceOp: 'append' }) + const got = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-w' as never })) + expect(got.result).toMatchObject({ ok: true, value: { attachment: ref } }) + const denied = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-other' as never })) + expect(denied.result).toMatchObject({ ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } } }) + await ctx.fiber.dispose() + }) + it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => { const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } } const cases: { label: string; append: (agent: Agent) => void }[] = [ diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index d69b2a9a28..8338becd7f 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -166,8 +166,12 @@ export class DeepSeekAdapter extends LlmAdapter { const contextWindow = configured?.contextWindow ?? this.options.defaultContextWindow return Promise.resolve({ + // The chat-completions wire route is text-only regardless of catalog + // membership, so the uncatalogued fallback declares the same negative + // capability — "unknown" here would let the host accept and persist + // images the serializer must then reject. ...configured === undefined - ? { provider, id: model, name: model } + ? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] } : modelInfo(provider, configured), ...contextWindow === undefined ? {} : { context: { contextWindow } }, ...this.options.defaults?.thinking === 'disabled' diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index a4716026cd..b13080bcea 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: d343449d1530bf70a3a8c57f883894e29c42d18f -README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12 +README.md: ecd6da304a894a687153608f5b468f867ad32e6b +README.zh.md: 2812f2d74ec3dbab521f8e3148e9efe2640c6929 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d343449d15..ecd6da304a 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, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `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 or modality 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 6ac57b1e60..2812f2d74e 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 51fe4be65c..8fba36e3eb 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -13,6 +13,7 @@ import type { LlmModelInfo, LlmResolvedModelInfo, LlmProviderInfo, + ModelModality, StreamChunk, } from './types.ts' import { freezeMessage, type Message } from './message.ts' @@ -253,6 +254,28 @@ 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[])] + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -277,13 +300,15 @@ 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) return { provider: model.provider, id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, - ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] }, - ...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] }, + ...inputModalities === undefined ? {} : { inputModalities }, + ...outputModalities === undefined ? {} : { outputModalities }, } }) } @@ -333,23 +358,17 @@ export class LlmService extends Service { 'INVALID_MODEL_CONTEXT', ) } - for (const modalities of [resolved.inputModalities, resolved.outputModalities]) { - if (modalities !== undefined && (!Array.isArray(modalities) || modalities.some(m => typeof m !== 'string'))) { - throw new LlmError( - `adapter returned invalid modality metadata for provider "${provider}" model "${model}"`, - 'INVALID_MODEL_INFO', - ) - } - } + // 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 info: LlmResolvedModelInfo = { provider, id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, - // Capability metadata rides through: an explicit modality omission is - // negative capability downstream preflights act on (image admission). - ...resolved.inputModalities === undefined ? {} : { inputModalities: resolved.inputModalities }, - ...resolved.outputModalities === undefined ? {} : { outputModalities: resolved.outputModalities }, + ...inputModalities === undefined ? {} : { inputModalities }, + ...outputModalities === undefined ? {} : { outputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, } const reasoning = resolved.reasoning diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 82d33f373b..e0d173599d 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1177,6 +1177,8 @@ 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)