-
Notifications
You must be signed in to change notification settings - Fork 544
fix(openai-chat): deliver tool-result images to vision models #912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,9 @@ interface ProviderAdapter { | |
| - Преобразует внутренние сообщения в роли OpenAI; инструменты отображаются в | ||
| `{type:"function", function:{…}}` и `tool_choice` (`auto`/`none`/`required` или именованная | ||
| функция). | ||
| - **Изображения из результатов инструментов** отправляются отдельным последующим user-сообщением | ||
| (части `image_url`) после закрытия раунда инструментов, так как содержимое `role:"tool"` может | ||
| быть только текстом; маркер `[image]` остаётся в сообщении инструмента как якорь. | ||
|
Comment on lines
+37
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Qualify direct image forwarding by model capability. Both localized pages describe direct
📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Path instructions |
||
| - **Переписывает идентификационный промпт Codex про GPT-5** в модельно-нейтральное вступление, | ||
| чтобы маршрутизируемые модели не заявляли, что они от OpenAI. | ||
| - **Прижимает `reasoning_effort`** к объявленному моделью подмножеству, когда точный уровень | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,6 +77,24 @@ function developerSystemText(message: OcxMessage): string | undefined { | |
| return message.content.map(part => (part as OcxTextContent).text).join(""); | ||
| } | ||
|
|
||
| /** | ||
| * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" | ||
| * content is text-only on every chat provider, so these ride in a follow-up user message instead of | ||
| * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https | ||
| * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64. | ||
| */ | ||
| function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] { | ||
| if (typeof content === "string") return []; | ||
| const parts: unknown[] = []; | ||
| for (const p of content) { | ||
| // Skip parts without a usable URL (the tool-output parser accepts the empty file_id shape): | ||
| // a {"url":""} part would fail the whole request where the "[image]" marker degrades safely. | ||
| if (p.type !== "image" || !p.imageUrl) continue; | ||
| parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a tool output contains a malformed AGENTS.md reference: src/AGENTS.md:L17-L19 Useful? React with 👍 / 👎. |
||
| } | ||
| return parts; | ||
| } | ||
|
|
||
| function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] { | ||
| const out: unknown[] = []; | ||
| const { context, options } = parsed; | ||
|
|
@@ -91,6 +109,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon | |
| interface PendingToolCall { id: string; name: string } | ||
| let pendingToolCalls: PendingToolCall[] = []; | ||
| let deferredBarrierMessages: unknown[] = []; | ||
| let pendingToolResultImageParts: unknown[] = []; | ||
| let mintedIdSeq = 0; | ||
| const seenWireCallIds = new Set<string>(); | ||
|
|
||
|
|
@@ -109,6 +128,22 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon | |
| deferredBarrierMessages = []; | ||
| }; | ||
|
|
||
| // Tool-result images collected during the open round land in ONE user vision message once the | ||
| // round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved | ||
| // user messages. Released before deferred barriers so the images stay adjacent to the results | ||
| // they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images). | ||
| const flushToolResultImages = (): void => { | ||
| if (pendingToolResultImageParts.length === 0) return; | ||
| out.push({ | ||
| role: "user", | ||
| content: [ | ||
| { type: "text", text: "[ocx] image output from the preceding tool result(s):" }, | ||
| ...pendingToolResultImageParts, | ||
| ], | ||
| }); | ||
| pendingToolResultImageParts = []; | ||
| }; | ||
|
|
||
| // Close an unresolved tool round with explicit unavailable-result messages. The wording | ||
| // must not claim interruption, success, failure, or user intent: execution status is | ||
| // UNKNOWN, and for user-input tools this must not read as an answer. | ||
|
|
@@ -122,6 +157,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon | |
| }); | ||
| } | ||
| pendingToolCalls = []; | ||
| flushToolResultImages(); | ||
| releaseDeferredBarriers(); | ||
| }; | ||
|
|
||
|
|
@@ -232,8 +268,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon | |
| tool_call_id: toolCallId, | ||
| content: contentPartsToText(msg.content), | ||
| }); | ||
| pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); | ||
| pendingToolCalls.splice(matchIdx, 1); | ||
| if (pendingToolCalls.length === 0) releaseDeferredBarriers(); | ||
| if (pendingToolCalls.length === 0) { | ||
| flushToolResultImages(); | ||
| releaseDeferredBarriers(); | ||
| } | ||
| } else { | ||
| if (!toolCallId) toolCallId = `call_orphan_${out.length}`; | ||
| // No matching call in the open round. Close any unresolved round first so the | ||
|
|
@@ -257,6 +297,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon | |
| tool_call_id: toolCallId, | ||
| content: contentPartsToText(msg.content), | ||
| }); | ||
| pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); | ||
| flushToolResultImages(); | ||
| } | ||
| break; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; | ||
| import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types"; | ||
|
|
||
| // Issue #888: role:"tool" content is text-only on chat-completions providers, so images inside a | ||
| // tool result were flattened to an "[image]" marker and vision-capable routed models hallucinated | ||
| // what they never saw. Tool-result images now ride in a follow-up user vision message released when | ||
| // the tool round closes, without splitting the round (strict providers reject interleaved users). | ||
|
|
||
| const provider: OcxProviderConfig = { | ||
| adapter: "openai-chat", | ||
| baseUrl: "https://example.test/v1", | ||
| apiKey: "sk-test", | ||
| authMode: "key", | ||
| }; | ||
|
|
||
| const IMAGE_URL = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM="; | ||
|
|
||
| interface ChatPart { | ||
| type: string; | ||
| text?: string; | ||
| image_url?: { url: string; detail?: string }; | ||
| } | ||
|
|
||
| interface ChatMsg { | ||
| role: string; | ||
| content?: string | ChatPart[]; | ||
| tool_calls?: { id: string; function: { name: string; arguments: string } }[]; | ||
| tool_call_id?: string; | ||
| } | ||
|
|
||
| function wire(messages: OcxMessage[]): ChatMsg[] { | ||
| const parsed: OcxParsedRequest = { | ||
| modelId: "test-model", | ||
| context: { messages }, | ||
| stream: false, | ||
| options: {}, | ||
| }; | ||
| const req = createOpenAIChatAdapter(provider).buildRequest(parsed) as { body: string }; | ||
| return (JSON.parse(req.body) as { messages: ChatMsg[] }).messages; | ||
| } | ||
|
|
||
| function user(text: string): OcxMessage { | ||
| return { role: "user", content: text, timestamp: 0 }; | ||
| } | ||
|
|
||
| function assistantWithCalls(calls: { id: string; name: string }[]): OcxMessage { | ||
| return { | ||
| role: "assistant", | ||
| content: calls.map(c => ({ type: "toolCall" as const, id: c.id, name: c.name, arguments: {} })), | ||
| timestamp: 0, | ||
| }; | ||
| } | ||
|
|
||
| function toolResult(callId: string, name: string, content: string | OcxContentPart[]): OcxMessage { | ||
| return { role: "toolResult", toolCallId: callId, toolName: name, content, isError: false, timestamp: 0 }; | ||
| } | ||
|
|
||
| /** The carrier is a user message whose parts start with an "[ocx]" text label followed by image_url parts. */ | ||
| function isImageCarrier(msg: ChatMsg): boolean { | ||
| if (msg.role !== "user" || !Array.isArray(msg.content)) return false; | ||
| const [head, ...rest] = msg.content; | ||
| return head?.type === "text" && typeof head.text === "string" && head.text.startsWith("[ocx]") | ||
| && rest.length > 0 && rest.every(p => p.type === "image_url"); | ||
| } | ||
|
|
||
| /** Every role:"tool" message must sit in an unbroken block right after its assistant tool_calls message. */ | ||
| function assertRoundsUnbroken(messages: ChatMsg[]): void { | ||
| for (let i = 0; i < messages.length; i++) { | ||
| const m = messages[i]; | ||
| if (m.role !== "tool") continue; | ||
| let j = i - 1; | ||
| while (j >= 0 && messages[j].role === "tool") j--; | ||
| expect(j).toBeGreaterThanOrEqual(0); | ||
| expect(messages[j].role).toBe("assistant"); | ||
| expect((messages[j].tool_calls ?? []).map(tc => tc.id)).toContain(m.tool_call_id); | ||
| } | ||
| } | ||
|
|
||
| test("tool-result images ride a follow-up user message; text, detail, and https URLs survive", () => { | ||
| const messages = wire([ | ||
| user("read the screenshot"), | ||
| assistantWithCalls([{ id: "call_1", name: "Read" }]), | ||
| toolResult("call_1", "Read", [ | ||
| { type: "text", text: "1 match found" }, | ||
| { type: "image", imageUrl: IMAGE_URL, detail: "high" }, | ||
| { type: "image", imageUrl: "https://example.test/shot.png" }, | ||
| { type: "image", imageUrl: "" }, // empty file_id shape: keeps its marker, never reaches the carrier | ||
| ]), | ||
| ]); | ||
| assertRoundsUnbroken(messages); | ||
| const tool = messages.find(m => m.role === "tool")!; | ||
| expect(tool.content).toBe("1 match found[image][image][image]"); | ||
| const carrier = messages.find(isImageCarrier)!; | ||
| expect(carrier).toBeDefined(); | ||
| expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1); | ||
| const parts = (carrier.content as ChatPart[]).filter(p => p.type === "image_url"); | ||
| expect(parts.map(p => p.image_url)).toEqual([ | ||
| { url: IMAGE_URL, detail: "high" }, | ||
| { url: "https://example.test/shot.png" }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("images from a multi-call round flush once, only after the whole round closes", () => { | ||
| const messages = wire([ | ||
| assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]), | ||
| toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]), | ||
| toolResult("call_2", "list", "file1.txt"), | ||
| ]); | ||
| assertRoundsUnbroken(messages); | ||
| const toolIdx = messages.map((m, i) => (m.role === "tool" ? i : -1)).filter(i => i >= 0); | ||
| expect(toolIdx).toEqual([toolIdx[0], toolIdx[0] + 1]); // nothing interleaves the round | ||
| const carriers = messages.filter(isImageCarrier); | ||
| expect(carriers.length).toBe(1); | ||
| expect(messages.indexOf(carriers[0])).toBe(toolIdx[1] + 1); | ||
| }); | ||
|
|
||
| test("orphan tool result with an image still emits the carrier after its synthesized pair", () => { | ||
| const messages = wire([ | ||
| user("hi"), | ||
| toolResult("call_orphan", "shot", [{ type: "image", imageUrl: IMAGE_URL }]), | ||
| ]); | ||
| assertRoundsUnbroken(messages); | ||
| const tool = messages.find(m => m.role === "tool")!; | ||
| expect(tool.content).toBe("[image]"); | ||
| const carrier = messages.find(isImageCarrier)!; | ||
| expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1); | ||
| }); | ||
|
|
||
| test("interrupted round: the synthetic closure still flushes collected images", () => { | ||
| const messages = wire([ | ||
| assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]), | ||
| toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]), | ||
| ]); | ||
| assertRoundsUnbroken(messages); | ||
| const carrier = messages.find(isImageCarrier)!; | ||
| expect(carrier).toBeDefined(); | ||
| expect(messages.indexOf(carrier)).toBe(messages.length - 1); | ||
| }); | ||
|
|
||
| test("image-free tool results emit no carrier and an unchanged wire", () => { | ||
| const messages = wire([ | ||
| user("hi"), | ||
| assistantWithCalls([{ id: "call_1", name: "list" }]), | ||
| toolResult("call_1", "list", "file1.txt"), | ||
| user("thanks"), | ||
| ]); | ||
| expect(messages.some(isImageCarrier)).toBe(false); | ||
| expect(messages.map(m => m.role)).toEqual(["user", "assistant", "tool", "user"]); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.