Compare commits

...
Author SHA1 Message Date
Aiden Cline 67e624f5f8 fix(ai): sanitize blank Bedrock text blocks 2026-08-30 22:55:37 -05:00
2 changed files with 194 additions and 13 deletions

No files matched your search

+27 -10
View File
@@ -29,6 +29,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "bedrock-converse"
const EMPTY_MESSAGE = "(empty message)"
const EMPTY_TOOL_OUTPUT = "(no tool output)"
export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth.js"
@@ -288,13 +290,16 @@ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
if (part.result.type === "text" || part.result.type === "error")
return [{ text: ProviderShared.toolResultText(part) }]
if (part.result.type === "text" || part.result.type === "error") {
const text = ProviderShared.toolResultText(part)
return [{ text: text.trim().length === 0 ? EMPTY_TOOL_OUTPUT : text }]
}
if (part.result.type === "json") return [{ json: part.result.value }]
const content: Array<Schema.Schema.Type<typeof BedrockToolResultContentItem>> = []
for (const item of part.result.value) {
if (item.type === "text") {
if (item.text.trim().length === 0) continue
content.push({ text: item.text })
continue
}
@@ -306,7 +311,7 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
})
content.push(media)
}
return content
return content.length === 0 ? [{ text: EMPTY_TOOL_OUTPUT }] : content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
@@ -319,6 +324,13 @@ const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
} satisfies BedrockToolResultBlock
})
const hasNonblankToolResultContent = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error")
return ProviderShared.toolResultText(part).trim().length > 0
if (part.result.type === "json") return true
return part.result.value.some((item) => item.type !== "text" || item.text.trim().length > 0)
}
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
request: LLMRequest,
breakpoints: BedrockCache.Breakpoints,
@@ -343,6 +355,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
if (!ProviderShared.supportsContent(part, ["text", "media"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"])
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
@@ -351,10 +364,16 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
const needsPlaceholder =
content.length === 0 ||
(message.content.some((part) => part.type === "text") &&
content.some((part) => "document" in part) &&
!content.some((part) => "text" in part))
const lowered = needsPlaceholder ? [{ text: EMPTY_MESSAGE }, ...content] : content
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...lowered] }
else messages.push({ role: "user", content: lowered })
continue
}
@@ -368,6 +387,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
"tool-call",
])
if (part.type === "text") {
if (part.text.trim().length === 0) continue
content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
@@ -401,7 +421,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
const cachePoint = hasNonblankToolResultContent(part) ? BedrockCache.block(breakpoints, part.cache) : undefined
if (cachePoint) content.push(cachePoint)
}
const previous = messages.at(-1)
@@ -415,10 +435,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
// System prompts share the cache-point convention: emit the text block, then
// optionally a positional `cachePoint` marker.
const lowerSystem = (
breakpoints: BedrockCache.Breakpoints,
system: ReadonlyArray<LLMRequest["system"][number]>,
) => {
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
const content = system
.filter((part) => part.text.length > 0)
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
@@ -208,6 +208,106 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("replaces blank-only user text with one compatibility block", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: " ", cache },
{ type: "text", text: "\t" },
{ type: "text", text: "\n\n" },
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([{ role: "user", content: [{ text: "(empty message)" }] }])
}),
)
it.effect("removes blank user text around meaningful text and media", () =>
Effect.gen(function* () {
const withText = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "\t\n" },
{ type: "text", text: " Keep this spacing. " },
{ type: "text", text: " " },
]),
],
cache: "none",
}),
)
const withImage = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: " \n\t " },
{ type: "media", mediaType: "image/png", data: "AAAA" },
]),
],
cache: "none",
}),
)
const withDocument = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: " \n\t " },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
]),
],
cache: "none",
}),
)
expect(withText.body.messages).toEqual([{ role: "user", content: [{ text: " Keep this spacing. " }] }])
expect(withImage.body.messages).toEqual([
{ role: "user", content: [{ image: { format: "png", source: { bytes: "AAAA" } } }] },
])
expect(withDocument.body.messages).toEqual([
{
role: "user",
content: [
{ text: "(empty message)" },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
],
},
])
}),
)
it.effect("drops blank assistant text without leaving an empty or cache-only message", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("First"),
Message.assistant([
{ type: "text", text: " ", cache },
{ type: "text", text: "\t" },
{ type: "text", text: "\n" },
]),
Message.user(" Second "),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([{ role: "user", content: [{ text: "First" }, { text: " Second " }] }])
}),
)
it.effect("prepares tool config with toolSpec and toolChoice", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -311,6 +411,65 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("replaces blank tool-result content while preserving identity and status", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "tool_1", name: "lookup", input: {} }),
ToolCallPart.make({ id: "tool_2", name: "lookup", input: {} }),
]),
Message.tool({ id: "tool_1", name: "lookup", result: " \t\n ", resultType: "error", cache }),
Message.tool({
id: "tool_2",
name: "lookup",
result: {
type: "content",
value: [
{ type: "text", text: " " },
{ type: "text", text: "\t\n" },
],
},
}),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [
{ toolUse: { toolUseId: "tool_1", name: "lookup", input: {} } },
{ toolUse: { toolUseId: "tool_2", name: "lookup", input: {} } },
],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_1",
content: [{ text: "(no tool output)" }],
status: "error",
},
},
{
toolResult: {
toolUseId: "tool_2",
content: [{ text: "(no tool output)" }],
status: "success",
},
},
],
},
])
}),
)
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
const input = {
@@ -435,7 +594,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("lowers image content in tool-result messages", () =>
it.effect("removes blank text from mixed tool-result content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -450,8 +609,10 @@ describe("Bedrock Converse route", () => {
result: {
type: "content",
value: [
{ type: "text", text: "Screenshot captured." },
{ type: "text", text: " \t\n " },
{ type: "text", text: " Screenshot captured. " },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
{ type: "text", text: " " },
],
},
}),
@@ -473,7 +634,10 @@ describe("Bedrock Converse route", () => {
{
toolResult: {
toolUseId: "tool_1",
content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }],
content: [
{ text: " Screenshot captured. " },
{ image: { format: "png", source: { bytes: "AAAA" } } },
],
status: "success",
},
},