Skip to content

Commit 63ecf45

Browse files
Jasmine ZhangJasmine Zhang
authored andcommitted
fix(llm): preserve structured tool results as text across providers
1 parent 6830aa3 commit 63ecf45

7 files changed

Lines changed: 155 additions & 11 deletions

src/llm/providers/google-shared.convert.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,29 @@ describe("google-shared convertMessages", () => {
375375
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
376376
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
377377
});
378+
379+
it("serializes structured tool results into function responses", () => {
380+
const model = makeModel("gemini-1.5-pro");
381+
const context = {
382+
messages: [
383+
{
384+
role: "toolResult",
385+
toolCallId: "call_1",
386+
toolName: "session_status",
387+
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
388+
isError: false,
389+
timestamp: 0,
390+
},
391+
],
392+
} as unknown as Context;
393+
394+
const contents = convertMessagesForTest(model, context);
395+
const toolResponsePart = contents[0]?.parts?.find(
396+
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
397+
);
398+
const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse");
399+
expect(asRecord(toolResponse.response).output).toBe(
400+
'{"type":"json","payload":{"sessionKey":"current","status":"ok"}}',
401+
);
402+
});
378403
});

src/llm/providers/google-shared.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
} from "../types.js";
3333
import type { AssistantMessageEventStream } from "../utils/event-stream.js";
3434
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
35+
import { extractToolResultText } from "./tool-result-text.js";
3536
import { transformMessages } from "./transform-messages.js";
3637

3738
export type GoogleApiType = "google-generative-ai" | "google-vertex";
@@ -278,8 +279,7 @@ export function convertMessages<T extends GoogleApiType>(
278279
});
279280
} else if (msg.role === "toolResult") {
280281
// Extract text and image content
281-
const textContent = msg.content.filter((c): c is TextContent => c.type === "text");
282-
const textResult = textContent.map((c) => c.text).join("\n");
282+
const textResult = extractToolResultText(msg.content as Array<Record<string, unknown>>);
283283
const imageContent = model.input.includes("image")
284284
? msg.content.filter((c): c is ImageContent => c.type === "image")
285285
: [];

src/llm/providers/openai-completions.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ vi.mock("openai", () => {
4040
return { default: MockOpenAI };
4141
});
4242

43-
import { streamOpenAICompletions, streamSimpleOpenAICompletions } from "./openai-completions.js";
43+
import {
44+
convertMessages,
45+
streamOpenAICompletions,
46+
streamSimpleOpenAICompletions,
47+
} from "./openai-completions.js";
4448

4549
const model = {
4650
id: "gpt-5.5",
@@ -1059,3 +1063,32 @@ describe("openai-completions stop-reason tool-call guard", () => {
10591063
expect(result.content.filter((b) => b.type === "toolCall")).toStrictEqual([]);
10601064
});
10611065
});
1066+
1067+
describe("convertMessages", () => {
1068+
it("serializes structured tool results as tool text", () => {
1069+
const params = convertMessages(
1070+
model,
1071+
{
1072+
messages: [
1073+
{
1074+
role: "toolResult",
1075+
toolCallId: "call_1",
1076+
toolName: "session_status",
1077+
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
1078+
isError: false,
1079+
timestamp: 0,
1080+
},
1081+
],
1082+
} as Context,
1083+
false,
1084+
);
1085+
1086+
expect(params).toContainEqual(
1087+
expect.objectContaining({
1088+
role: "tool",
1089+
tool_call_id: "call_1",
1090+
content: '{"type":"json","payload":{"sessionKey":"current","status":"ok"}}',
1091+
}),
1092+
);
1093+
});
1094+
});

src/llm/providers/openai-completions.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi
5151
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
5252
import { mapOpenAIStopReason } from "./openai-stop-reason.js";
5353
import { buildBaseOptions } from "./simple-options.js";
54+
import { extractToolResultText } from "./tool-result-text.js";
5455
import { transformMessages } from "./transform-messages.js";
5556

5657
/**
@@ -1128,10 +1129,7 @@ export function convertMessages(
11281129
const toolMsg = transformedMessages[j] as ToolResultMessage;
11291130

11301131
// Extract text and image content
1131-
const textResult = toolMsg.content
1132-
.filter(isTextContentBlock)
1133-
.map((block) => block.text)
1134-
.join("\n");
1132+
const textResult = extractToolResultText(toolMsg.content as Array<Record<string, unknown>>);
11351133
const hasImages = toolMsg.content.some((c) => c.type === "image");
11361134

11371135
// Always send tool result with text (or placeholder if only images)

src/llm/providers/openai-responses-shared.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,39 @@ describe("convertResponsesMessages", () => {
527527
summary: [],
528528
});
529529
});
530+
531+
it("serializes structured tool results as text instead of image placeholders", () => {
532+
const input = convertResponsesMessages(
533+
nativeOpenAIModel,
534+
{
535+
systemPrompt: "system",
536+
messages: [
537+
{
538+
role: "toolResult",
539+
toolCallId: "call_structured",
540+
toolName: "session_status",
541+
content: [
542+
{
543+
type: "json",
544+
payload: { sessionKey: "current", model: "openai/gpt-5.4", status: "ok" },
545+
},
546+
],
547+
isError: false,
548+
timestamp: 1,
549+
},
550+
],
551+
} satisfies Context,
552+
allowedToolCallProviders,
553+
{ includeSystemPrompt: false, replayResponsesItemIds: false },
554+
) as unknown as Array<Record<string, unknown>>;
555+
556+
expect(input).toContainEqual({
557+
type: "function_call_output",
558+
call_id: "call_structured",
559+
output:
560+
'{"type":"json","payload":{"sessionKey":"current","model":"openai/gpt-5.4","status":"ok"}}',
561+
});
562+
});
530563
});
531564

532565
describe("processResponsesStream", () => {

src/llm/providers/openai-responses-shared.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { headersToRecord } from "../utils/headers.js";
4545
import { parseStreamingJson } from "../utils/json-parse.js";
4646
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
4747
import { convertResponsesToolPayload, convertResponsesTools } from "./openai-responses-tools.js";
48+
import { extractToolResultText } from "./tool-result-text.js";
4849
import { transformMessages } from "./transform-messages.js";
4950

5051
// =============================================================================
@@ -372,10 +373,7 @@ export function convertResponsesMessages<TApi extends Api>(
372373
}
373374
messages.push(...output);
374375
} else if (msg.role === "toolResult") {
375-
const textResult = msg.content
376-
.filter((c): c is TextContent => c.type === "text")
377-
.map((c) => c.text)
378-
.join("\n");
376+
const textResult = extractToolResultText(msg.content as Array<Record<string, unknown>>);
379377
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
380378
const hasText = textResult.length > 0;
381379
const [callId] = msg.toolCallId.split("|");
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
2+
3+
type ToolResultContentBlock = Record<string, unknown>;
4+
5+
function stringifyStructuredBlock(block: ToolResultContentBlock): string | undefined {
6+
const seen = new WeakSet<object>();
7+
try {
8+
const serialized = JSON.stringify(block, (_key, value) => {
9+
if (typeof value === "string") {
10+
return value.replace(
11+
/data:[^"'\\\s]+/gi,
12+
(match) => `[inline data URI: ${match.length} chars]`,
13+
);
14+
}
15+
if (!value || typeof value !== "object") {
16+
return value;
17+
}
18+
if (seen.has(value)) {
19+
return "[Circular]";
20+
}
21+
seen.add(value);
22+
return value;
23+
});
24+
if (!serialized || serialized === "{}") {
25+
return undefined;
26+
}
27+
return serialized.length > 1_000
28+
? `${serialized.slice(0, 1_000)}... (${serialized.length} chars)`
29+
: serialized;
30+
} catch {
31+
return undefined;
32+
}
33+
}
34+
35+
export function extractToolResultText(blocks: readonly ToolResultContentBlock[]): string {
36+
const parts: string[] = [];
37+
for (const block of blocks) {
38+
if (!block || typeof block !== "object") {
39+
continue;
40+
}
41+
if (block.type === "image") {
42+
continue;
43+
}
44+
if (block.type === "text") {
45+
const text = typeof block.text === "string" ? block.text : "";
46+
if (text) {
47+
parts.push(text);
48+
}
49+
continue;
50+
}
51+
const structured = stringifyStructuredBlock(block);
52+
if (structured) {
53+
parts.push(structured);
54+
}
55+
}
56+
return sanitizeSurrogates(parts.join("\n"));
57+
}

0 commit comments

Comments
 (0)