mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): treat whitespace-only tool output as empty
Whitespace-only tool results (e.g. a bare \r\n from bash) were sent verbatim to OpenAI Completions, OpenAI Responses, and Google providers. Some providers reject that with a 400, which then repeats on every request in the session. They now use the same placeholder as empty output. closes #8720
This commit is contained in:
parent
6fcb48670f
commit
4dd2b149d1
7 changed files with 95 additions and 66 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720))
|
||||
|
||||
## [1.3.0] - 2026-10-15
|
||||
|
||||
### Breaking Changes
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
|||
? msg.content.filter((c): c is ImageContent => c.type === "image")
|
||||
: [];
|
||||
|
||||
const hasText = textResult.length > 0;
|
||||
const hasText = textResult.trim().length > 0;
|
||||
const hasImages = imageContent.length > 0;
|
||||
|
||||
// Gemini 3+ models support multimodal function responses with images nested inside
|
||||
|
|
|
|||
|
|
@ -1420,7 +1420,7 @@ export function convertMessages(
|
|||
const hasImages = toolMsg.content.some((c) => c.type === "image");
|
||||
|
||||
// Always send tool result with text (or placeholder if only images)
|
||||
const hasText = textResult.length > 0;
|
||||
const hasText = textResult.trim().length > 0;
|
||||
const toolResultText = hasText ? textResult : hasImages ? "(see attached image)" : "(no tool output)";
|
||||
// Some providers require the 'name' field in tool results
|
||||
const toolResultMsg: ChatCompletionToolMessageParam = {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ function convertToolResultOutput<TApi extends Api>(
|
|||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const images = content.filter((c): c is ImageContent => c.type === "image");
|
||||
const hasText = textResult.length > 0;
|
||||
const hasText = textResult.trim().length > 0;
|
||||
|
||||
if (images.length === 0 || !model.input.includes("image")) {
|
||||
return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)");
|
||||
|
|
|
|||
|
|
@ -100,4 +100,21 @@ describe("google-shared image tool result routing", () => {
|
|||
expect(imageResponse?.parts).toHaveLength(1);
|
||||
expect(imageResponse?.parts?.[0]?.inlineData).toBeTruthy();
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/8720
|
||||
it("treats whitespace-only tool result text as no text", () => {
|
||||
const model = makeModel("google-generative-ai", "google", "gemini-3-pro-preview");
|
||||
const context = makeContext(model);
|
||||
for (const message of context.messages) {
|
||||
if (message.role !== "toolResult") continue;
|
||||
if (message.toolCallId === "call_a") message.content = [{ type: "text", text: "\r\n" }];
|
||||
if (message.toolCallId === "call_img") message.content.unshift({ type: "text", text: " \n" });
|
||||
}
|
||||
const contents = convertMessages(model, normalizeContext(context));
|
||||
|
||||
const responses = contents[2].parts?.map((part) => part.functionResponse?.response);
|
||||
expect(responses?.[0]).toEqual({ output: "" });
|
||||
expect(responses?.[1]).toEqual({ output: "(see attached image)" });
|
||||
expect(responses?.[2]).toEqual({ output: "beta text" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,12 +57,12 @@ function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessa
|
|||
};
|
||||
}
|
||||
|
||||
function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
||||
function buildEmptyToolResult(toolCallId: string, timestamp: number, text = ""): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: "bash",
|
||||
content: [{ type: "text", text: "" }],
|
||||
content: [{ type: "text", text }],
|
||||
isError: false,
|
||||
timestamp,
|
||||
};
|
||||
|
|
@ -144,38 +144,42 @@ describe("openai-completions convertMessages", () => {
|
|||
expect(imageParts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("uses '(no tool output)' placeholder for empty tool results without images", () => {
|
||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
||||
const model: Model<"openai-completions"> = {
|
||||
...baseModel,
|
||||
api: "openai-completions",
|
||||
input: ["text", "image"],
|
||||
};
|
||||
// Whitespace-only cases: https://github.com/earendil-works/pi/issues/8720
|
||||
it.each(["", "\r\n", " \n\t"])(
|
||||
"uses '(no tool output)' placeholder for tool result text %j without images",
|
||||
(text) => {
|
||||
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini");
|
||||
const model: Model<"openai-completions"> = {
|
||||
...baseModel,
|
||||
api: "openai-completions",
|
||||
input: ["text", "image"],
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: emptyUsage,
|
||||
stopReason: "toolUse",
|
||||
timestamp: now,
|
||||
};
|
||||
const now = Date.now();
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: emptyUsage,
|
||||
stopReason: "toolUse",
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistantMessage,
|
||||
buildEmptyToolResult("tool-1", now + 1),
|
||||
],
|
||||
});
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistantMessage,
|
||||
buildEmptyToolResult("tool-1", now + 1, text),
|
||||
],
|
||||
});
|
||||
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined;
|
||||
expect(toolMessage).toBeTruthy();
|
||||
expect(toolMessage?.content).toBe("(no tool output)");
|
||||
expect(toolMessage?.content).not.toContain("see attached image");
|
||||
});
|
||||
const messages = convertMessages(model, context, compat);
|
||||
const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined;
|
||||
expect(toolMessage).toBeTruthy();
|
||||
expect(toolMessage?.content).toBe("(no tool output)");
|
||||
expect(toolMessage?.content).not.toContain("see attached image");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,47 +12,51 @@ const usage: Usage = {
|
|||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
|
||||
function buildEmptyToolResult(toolCallId: string, timestamp: number, text = ""): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId,
|
||||
toolName: "bash",
|
||||
content: [{ type: "text", text: "" }],
|
||||
content: [{ type: "text", text }],
|
||||
isError: false,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenAI Responses convertResponsesMessages empty tool result", () => {
|
||||
it("uses '(no tool output)' placeholder for empty tool results without images", () => {
|
||||
const model = getModel("openai", "gpt-4o-mini");
|
||||
const now = Date.now();
|
||||
const assistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage,
|
||||
stopReason: "toolUse",
|
||||
timestamp: now,
|
||||
};
|
||||
// Whitespace-only cases: https://github.com/earendil-works/pi/issues/8720
|
||||
it.each(["", "\r\n", " \n\t"])(
|
||||
"uses '(no tool output)' placeholder for tool result text %j without images",
|
||||
(text) => {
|
||||
const model = getModel("openai", "gpt-4o-mini");
|
||||
const now = Date.now();
|
||||
const assistant: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage,
|
||||
stopReason: "toolUse",
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistant,
|
||||
buildEmptyToolResult("tool-1", now + 1),
|
||||
],
|
||||
});
|
||||
const context = normalizeContext({
|
||||
messages: [
|
||||
{ role: "user", content: "Run the command", timestamp: now - 1 },
|
||||
assistant,
|
||||
buildEmptyToolResult("tool-1", now + 1, text),
|
||||
],
|
||||
});
|
||||
|
||||
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
|
||||
const functionCallOutput = input.find((item) => item.type === "function_call_output") as
|
||||
| { type: "function_call_output"; output: string }
|
||||
| undefined;
|
||||
const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"]));
|
||||
const functionCallOutput = input.find((item) => item.type === "function_call_output") as
|
||||
| { type: "function_call_output"; output: string }
|
||||
| undefined;
|
||||
|
||||
expect(functionCallOutput).toBeTruthy();
|
||||
expect(functionCallOutput?.output).toBe("(no tool output)");
|
||||
expect(functionCallOutput?.output).not.toContain("see attached image");
|
||||
});
|
||||
expect(functionCallOutput).toBeTruthy();
|
||||
expect(functionCallOutput?.output).toBe("(no tool output)");
|
||||
expect(functionCallOutput?.output).not.toContain("see attached image");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue