Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/ai/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,10 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMReq
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content, request: LLMRequest) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
if (item.type === "text") {
if (item.text.trim() === "") return
return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
}
return yield* lowerMedia(ProviderShared.toolFileMedia(item), request)
})

Expand All @@ -825,7 +828,8 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart
if (part.result.type === "error") return lowerToolErrorContent(part, rich ?? [])
// Text and json results stay as a string for backward compatibility with existing cassettes.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
return yield* Effect.forEach(part.result.value, (item) => lowerToolResultContentItem(item, request))
const blocks = yield* Effect.forEach(part.result.value, (item) => lowerToolResultContentItem(item, request))
return blocks.flatMap((block) => (block === undefined ? [] : [block]))
})

const lowerToolErrorContent = (part: ToolResultPart, content: ReadonlyArray<Tool.Content>) => {
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/protocols/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* (
const content: Array<Schema.Schema.Type<typeof BedrockToolResultContentItem>> = []
for (const item of part.result.value) {
if (item.type === "text") {
if (item.text.trim() === "") continue
content.push({ text: item.text })
continue
}
Expand Down
11 changes: 8 additions & 3 deletions packages/ai/src/tool-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js

const EMPTY_TOOL_OUTPUT = "(no tool output)"
const MISSING_TOOL_RESULT = "Tool result missing"
const blankText = (value: unknown) => typeof value === "string" && value.trim() === ""

export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
const normalized: Message[] = []
Expand Down Expand Up @@ -86,13 +87,17 @@ function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefine
call === undefined || (part.name === call.name && part.namespace === call.namespace)
? part
: { ...part, name: call.name, namespace: call.namespace }
if (named.result.type === "text" && named.result.value === "")
if (named.result.type === "text" && blankText(named.result.value))
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (named.result.type === "error" && named.result.value === "")
if (named.result.type === "error" && blankText(named.result.value))
return { ...named, result: { ...named.result, value: EMPTY_TOOL_OUTPUT } }
if (named.result.type !== "content") return named
// Exact "" is not a separator. Drop it, then replace the array only when every remaining
// item is blank and there is no media. Whitespace such as "\n" stays: Mistral joins text
// with "", so ["foo", "\n", "bar"] must remain "foo\nbar".
const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "")
if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (value.every((item) => item.type === "text" && blankText(item.text)))
return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } }
if (value.length === named.result.value.length) return named
return { ...named, result: { type: "content", value } }
}
43 changes: 42 additions & 1 deletion packages/ai/test/provider/anthropic-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,47 @@ describe("Anthropic Messages route", () => {
}),
)

it.effect("drops whitespace-only tool-result text and keeps non-blank text", () =>
Effect.gen(function* () {
const spaced = " Keep this spacing. "
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [
{ type: "text", text: " \n\t" },
{ type: "text", text: spaced },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
],
}),
],
}),
)
const blank = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({ id: "call_1", name: "read", resultType: "text", result: " \n\t" }),
],
}),
)

expect(expectToolResult(prepared.body).content).toEqual([
{ type: "text", text: spaced },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
])
expect(expectToolResult(blank.body).content).toBe("(no tool output)")
}),
)

it.effect("lowers single-image tool-result content as a structured image block", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
Expand Down Expand Up @@ -1036,7 +1077,7 @@ describe("Anthropic Messages route", () => {
const prepared = yield* compileErrorToolResult(" \n", [{ type: "text", text: "\t " }])
const result = expectToolResult(prepared.body)
expect(result.is_error).toBe(true)
expect(result.content).toEqual([{ type: "text", text: "[tool error]" }])
expect(result.content).toEqual([{ type: "text", text: "(no tool output)" }])
expectNoEmptyText(result.content)
}),
)
Expand Down
47 changes: 47 additions & 0 deletions packages/ai/test/provider/bedrock-converse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,53 @@ describe("Bedrock Converse route", () => {
}),
)

it.effect("drops blank tool-result text and keeps the image", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_blank_text",
model,
messages: [
Message.user("Capture the screen."),
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]),
Message.tool({
id: "tool_1",
name: "screenshot",
result: {
type: "content",
value: [
{ type: "text", text: " " },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
],
},
}),
],
cache: "none",
}),
)

expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "Capture the screen." }] },
{
role: "assistant",
content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_1",
content: [{ image: { format: "png", source: { bytes: "AAAA" } } }],
status: "success",
},
},
],
},
])
}),
)

it.effect("hoists tool-result images beside the result for Bedrock GPT models", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
Expand Down
25 changes: 25 additions & 0 deletions packages/ai/test/provider/mistral-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,31 @@ describe("Mistral Chat", () => {
}),
)

it.effect("keeps a whitespace separator when joining text-only tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.tool({
id: "Ab12Cd34E",
name: "lookup",
resultType: "content",
result: [
{ type: "text", text: "foo" },
{ type: "text", text: "\n" },
{ type: "text", text: "bar" },
],
}),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "foo\nbar" },
])
}),
)

it.effect("streams ordered thinking and text and replays native thinking metadata", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Expand Down
45 changes: 43 additions & 2 deletions packages/ai/test/tool-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe("tool history normalization", () => {
expect(normalizeToolHistory([Message.assistant(hosted)])).toEqual([Message.assistant(hosted)])
})

test("normalizes empty results without changing whitespace or media", () => {
test("normalizes empty and whitespace-only results without changing media", () => {
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
const normalized = normalizeToolHistory([
Message.assistant([
Expand All @@ -80,7 +80,48 @@ describe("tool history normalization", () => {
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } },
{ type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } },
{
type: "tool-result",
id: "whitespace",
name: "whitespace",
result: { type: "text", value: "(no tool output)" },
},
])
})

test("treats whitespace-only text and content as empty and leaves mixed content unchanged", () => {
const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" }
const spaced = " Keep this spacing. "
const separated = [
{ type: "text" as const, text: "foo" },
{ type: "text" as const, text: "\n" },
{ type: "text" as const, text: "bar" },
]
const mixed = [{ type: "text" as const, text: " \n\t" }, { type: "text" as const, text: spaced }, media]
const normalized = normalizeToolHistory([
Message.assistant([
toolCall("text"),
toolCall("error"),
toolCall("content"),
toolCall("spaced"),
toolCall("separated"),
toolCall("mixed"),
]),
toolResult("text", " \n\t", "text", "text"),
toolResult("error", " \n\t", "error", "error"),
toolResult("content", [{ type: "text", text: " \n\t" }], "content", "content"),
toolResult("spaced", spaced, "spaced", "text"),
toolResult("separated", separated, "separated", "content"),
toolResult("mixed", mixed, "mixed", "content"),
])

expect(normalized.slice(1).map((message) => message.content[0])).toEqual([
{ type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } },
{ type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } },
{ type: "tool-result", id: "spaced", name: "spaced", result: { type: "text", value: spaced } },
{ type: "tool-result", id: "separated", name: "separated", result: { type: "content", value: separated } },
{ type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: mixed } },
])
})

Expand Down
Loading