Explorar el Código

fix(llm-pi-ai): reject leading system images before splitting

Address ds-review-bot thread 3921300999 (PRRT_kwDOS3Pfcs6eyLsz). The synchronous conversion removed a leading system message before its image check, silently discarding image-only, mixed, and nested image content. Reuse assertSupportedImageRoles on the unsplit history, matching the image-aware path without changing text prompt precedence or user-image storage requirements.

Regression: all three leading-system image cases failed against 8082f4a950 (expected rejection, received a context). The focused context suite now passes 20 tests, including both conversion paths, no attachment reads on rejection, empty/text leading prompts, later systems, and explicit options.system precedence. Exact src/context.ts coverage is 100% statements, branches, functions, and lines. Updated the paired README limitation and synchronous JSDoc.
Tianyi Cui hace 1 mes
padre
commit
454b80b90e

+ 2 - 2
packages/llm/llm-pi-ai/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-pi-ai/README.md
-README.md: e0847b9e03b38f25f31152ba68e9fdf5e9b94c56
-README.zh.md: ed1cd312cf417a38d1224bc11f24233ee24430cc
+README.md: 030d941b59812d539d65e70eb61e8a27da0f23d1
+README.zh.md: 4d540c22bb333b3bd97da88f7e5f3f598ad55f76

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -219,7 +219,7 @@ These limits define where the adapter stops and future work begins. They are cur
 - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request.
 - **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`.
 - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers.
-- **Only a leading in-history `system` message becomes pi-ai's `systemPrompt`** — pi-ai has one system slot, so a later `system` message, or a leading one when `GenerateOptions.system` is also set, folds into a `user` message at its position; provider-specific placement of the prompt follows pi-ai rather than a harness-owned wire override.
+- **Only a leading in-history `system` message becomes pi-ai's `systemPrompt`** — pi-ai has one system slot, so a later `system` message, or a leading one when `GenerateOptions.system` is also set, folds into a `user` message at its position; provider-specific placement of the prompt follows pi-ai rather than a harness-owned wire override. Images in system or assistant history, including the leading system message, fail with `UNSUPPORTED_CONTENT` on both conversion paths.
 - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers.
 - **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -219,7 +219,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。
 - **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。
 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。
-- **只有历史中首条 `system` 消息会成为 pi-ai 的 `systemPrompt`**——pi-ai 只有一个系统槽位,因此后续的 `system` 消息,或在同时设置了 `GenerateOptions.system` 时的首条消息,会在原位置折叠为 `user` 消息;系统提示的提供方专属放置遵循 pi-ai,而非 harness 自有的协议覆盖。
+- **只有历史中首条 `system` 消息会成为 pi-ai 的 `systemPrompt`**——pi-ai 只有一个系统槽位,因此后续的 `system` 消息,或在同时设置了 `GenerateOptions.system` 时的首条消息,会在原位置折叠为 `user` 消息;系统提示的提供方专属放置遵循 pi-ai,而非 harness 自有的协议覆盖。system 或 assistant 历史中的图片(包括首条系统消息中的图片)在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。
 - **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。
 - **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent 步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。
 

+ 2 - 0
packages/llm/llm-pi-ai/src/context.ts

@@ -175,6 +175,7 @@ function appendAssistant(
 }
 
 function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext {
+  assertSupportedImageRoles(options.messages)
   const split = splitSystemPrompt(options)
   const toolNames = new Map<ToolCallId, string>()
   const messages: PiMessage[] = []
@@ -231,6 +232,7 @@ export interface PiImageRequestContext {
  * @param images - absent; selects the synchronous conversion.
  * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
  * @returns the pi-ai context; `tools` is omitted when the request declares none.
+ * @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message.
  */
 export function toPiContext(
   options: GenerateOptions,

+ 23 - 0
packages/llm/llm-pi-ai/tests/context.spec.ts

@@ -472,6 +472,29 @@ describe('pi-ai system prompt source', () => {
   const leading = history('system', [{ type: 'text', text: 'lead ' }, { type: 'text', text: 'rule' }])
   const question = user([{ type: 'text', text: 'hi' }])
 
+  it.each<{ label: string; content: ContentBlock[] }>([
+    { label: 'image-only', content: [{ type: 'image', attachment: ref }] },
+    { label: 'text and image', content: [{ type: 'text', text: 'rule' }, { type: 'image', attachment: ref }] },
+    {
+      label: 'nested image',
+      content: [{
+        type: 'tool-result',
+        toolCallId: ToolCallId('system-image'),
+        content: [{ type: 'image', attachment: ref }],
+      }],
+    },
+  ])('rejects a leading system $label on both conversion paths', async ({ content }) => {
+    const options: GenerateOptions = { ...base, messages: [history('system', content), question] }
+    const error = expect.objectContaining({
+      code: 'UNSUPPORTED_CONTENT',
+      message: 'pi-ai cannot represent an image in an in-history system message',
+    })
+    expect(() => toPiContext(options)).toThrowError(error)
+    const readImageRequest = vi.fn()
+    await expect(toPiContext(options, imageContext(projectionStore(readImageRequest)))).rejects.toMatchObject(error)
+    expect(readImageRequest).not.toHaveBeenCalled()
+  })
+
   it('maps a leading system message to systemPrompt on both conversion paths', async () => {
     const options: GenerateOptions = { ...base, messages: [leading, question] }
     const expected = {