mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ai): preserve tool-result boundary whitespace for Mistral and Ollama (#128266)
The shared formatToolResultText helper emitted its trim() result instead of using it as an emptiness predicate only, so native Mistral and Ollama requests silently stripped leading indentation and trailing whitespace from durable tool output (#127587). Chat Completions already emits the original sanitized text and uses trim solely to detect blank content; align the shared formatter with that contract. Blank output keeps the placeholder fallback, error prefix, and omitted-media suffix. Closes #127587 AI-assisted (Claude); shared + Mistral + Ollama boundary regression tests fail pre-fix and pass post-fix. Co-authored-by: Parker Fawcett <Parkerscottfawcett@gmail.com>
This commit is contained in:
@@ -802,6 +802,17 @@ describe("convertToOllamaMessages", () => {
|
||||
expect(result).toEqual([{ role: "tool", content: "file1.txt\nfile2.txt" }]);
|
||||
});
|
||||
|
||||
it("preserves significant boundary whitespace in tool results", () => {
|
||||
const result = convertToOllamaMessages([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_ws",
|
||||
content: [{ type: "text", text: " indented\n" }],
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([{ role: "tool", content: " indented\n", tool_call_id: "call_ws" }]);
|
||||
});
|
||||
|
||||
it("converts SDK 'toolResult' role to Ollama 'tool' role", () => {
|
||||
const messages = [{ role: "toolResult", content: "command output here" }];
|
||||
const result = convertToOllamaMessages(messages);
|
||||
|
||||
@@ -802,6 +802,22 @@ describe("Mistral provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves tool-result boundary whitespace in the request payload", async () => {
|
||||
const testContext = makeMistralToolResultContext("read_file", [
|
||||
{ type: "text", text: " indented\n" },
|
||||
]);
|
||||
|
||||
await runMistralFixture(testContext);
|
||||
|
||||
const payload = mistralMockState.payloads[0] as {
|
||||
messages: Array<{ role: string; content: Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
const toolMessage = payload.messages.find((message) => message.role === "tool");
|
||||
const toolContent = Array.isArray(toolMessage?.content) ? toolMessage.content : [];
|
||||
const textBlock = toolContent.find((block) => block.type === "text");
|
||||
expect(textBlock?.text).toBe(" indented\n");
|
||||
});
|
||||
|
||||
it("serializes structured non-image blocks in tool results as JSON text", async () => {
|
||||
// Prove the host redaction port is applied to structured tool-result text.
|
||||
configureAiTransportHost({
|
||||
|
||||
@@ -3,10 +3,44 @@ import {
|
||||
describeToolResultMediaPlaceholder,
|
||||
describeUnsupportedToolResultMedia,
|
||||
extractToolResultText,
|
||||
formatToolResultText,
|
||||
hasMediaPayload,
|
||||
isImageWithMediaPayload,
|
||||
} from "./tool-result-text.js";
|
||||
|
||||
describe("formatToolResultText", () => {
|
||||
it("preserves significant boundary whitespace in nonblank tool output", () => {
|
||||
expect(formatToolResultText({ text: " indented\n", isError: false })).toBe(" indented\n");
|
||||
expect(formatToolResultText({ text: "row1 \nrow2\n", isError: false })).toBe(
|
||||
"row1 \nrow2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to placeholders only for blank tool output", () => {
|
||||
expect(formatToolResultText({ text: " \n\t", isError: false })).toBe("(no tool output)");
|
||||
expect(
|
||||
formatToolResultText({ text: "", mediaPlaceholder: "(see attached image)", isError: false }),
|
||||
).toBe("(see attached image)");
|
||||
});
|
||||
|
||||
it("keeps the error prefix on unmodified output", () => {
|
||||
expect(formatToolResultText({ text: " failed ", isError: true })).toBe(
|
||||
"[tool error] failed ",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the omitted-media suffix after unmodified output", () => {
|
||||
const text = "line with trailing spaces ";
|
||||
expect(
|
||||
formatToolResultText({
|
||||
text,
|
||||
omittedMediaPlaceholder: "[tool image omitted]",
|
||||
isError: false,
|
||||
}),
|
||||
).toBe(`${text}\n[tool image omitted]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMediaPayload", () => {
|
||||
it("requires non-empty inline data instead of media metadata", () => {
|
||||
expect(hasMediaPayload({ type: "image", data: "aW1n", mimeType: "image/png" })).toBe(true);
|
||||
|
||||
@@ -223,8 +223,11 @@ export function formatToolResultText(params: {
|
||||
isError: boolean;
|
||||
}): string {
|
||||
const trimmed = params.text.trim();
|
||||
// trim() is only an emptiness predicate here: tool output boundary
|
||||
// whitespace (indentation, trailing newlines) is significant durable
|
||||
// content, so the nonblank body must emit params.text unmodified.
|
||||
const body = trimmed
|
||||
? `${trimmed}${params.omittedMediaPlaceholder ? `\n${params.omittedMediaPlaceholder}` : ""}`
|
||||
? `${params.text}${params.omittedMediaPlaceholder ? `\n${params.omittedMediaPlaceholder}` : ""}`
|
||||
: (params.omittedMediaPlaceholder ?? params.mediaPlaceholder ?? "(no tool output)");
|
||||
return `${params.isError ? "[tool error] " : ""}${body}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user