pi-sap-aicore 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.6] - 2026-07-04
11
+
12
+ ### Fixed
13
+
14
+ - Bedrock (Anthropic/Claude) and Vertex AI (Gemini) foundation routes no
15
+ longer emit empty or whitespace-only text blocks. Pi contexts legitimately
16
+ contain them — errored assistant turns persist with `content: []`,
17
+ thinking-only turns carry no text — and the old `{ text: " " }` placeholder
18
+ was itself whitespace-only, so Anthropic rejected every subsequent request
19
+ in the conversation with HTTP 400 "messages: text content blocks must
20
+ contain non-whitespace text". Empty messages are now dropped (role
21
+ coalescing re-merges the neighbours), empty text parts are filtered, and
22
+ empty tool-result text gets a non-whitespace fallback.
23
+
24
+ ### Added
25
+
26
+ - Added `scripts/test-empty-content-blocks.mjs`, a credential-free regression
27
+ test covering both translators, and wired it into `npm test`.
28
+
29
+ ## [0.3.5] - 2026-07-01
30
+
31
+ ### Fixed
32
+
33
+ - Direct AWS Bedrock (Anthropic/Claude) foundation route now batches all
34
+ corresponding `toolResult` blocks immediately after assistant `toolUse` blocks
35
+ before appending screenshot/image content. This fixes Anthropic HTTP 400
36
+ failures from Opus 4.8 such as "`tool_use` ids were found without
37
+ `tool_result` blocks immediately after" on tool-heavy coding-agent turns.
38
+
39
+ ### Added
40
+
41
+ - Added `scripts/test-bedrock-tool-results.mjs`, a credential-free regression
42
+ test for Bedrock/Anthropic tool-result ordering, and wired it into `npm test`.
43
+
10
44
  ## [0.3.4] - 2026-07-01
11
45
 
12
46
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sap-aicore",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "SAP AI Core (orchestration + foundation) provider for the pi coding agent",
5
5
  "license": "MIT",
6
6
  "author": "Tim Pearson (https://github.com/ttiimmaahh)",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "scripts": {
33
33
  "update-models": "node scripts/update-models.mjs",
34
- "test": "node scripts/test-vertex-tool-schema.mjs",
34
+ "test": "node scripts/test-vertex-tool-schema.mjs && node scripts/test-bedrock-tool-results.mjs && node scripts/test-empty-content-blocks.mjs",
35
35
  "validate:foundation": "node scripts/validate-foundation-executables.mjs",
36
36
  "prepublishOnly": "tsc --noEmit && npm test"
37
37
  },
@@ -45,8 +45,8 @@
45
45
  "@earendil-works/pi-coding-agent": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "^0.79.2",
49
- "@earendil-works/pi-coding-agent": "^0.79.2",
48
+ "@earendil-works/pi-ai": "^0.80.3",
49
+ "@earendil-works/pi-coding-agent": "^0.80.3",
50
50
  "typescript": "^6.0.3"
51
51
  },
52
52
  "engines": {
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // Offline regression test for Bedrock/Anthropic tool_result ordering.
3
+ //
4
+ // Anthropic models behind Bedrock require the user message immediately after an
5
+ // assistant tool_use turn to begin with one tool_result block per tool_use id.
6
+ // Pi stores each tool result as a separate top-level message, and screenshot
7
+ // tool results can include images. A naive role coalesce produces:
8
+ // assistant(tool_use a,b), user(tool_result a, image, tool_result b)
9
+ // which Anthropic rejects because tool_result b is not in the required prefix.
10
+ // This test makes no network calls.
11
+
12
+ import { pathToFileURL } from "node:url";
13
+ import { join } from "node:path";
14
+
15
+ const ROOT = new URL("..", import.meta.url).pathname;
16
+
17
+ const { piContextToBedrockConverse } = await import(
18
+ pathToFileURL(join(ROOT, "src/translate-foundation-bedrock.ts")).href
19
+ );
20
+
21
+ let failures = 0;
22
+ function check(condition, message) {
23
+ if (condition) {
24
+ console.log(` ✓ ${message}`);
25
+ return;
26
+ }
27
+ console.error(` ❌ ${message}`);
28
+ failures++;
29
+ }
30
+
31
+ const context = {
32
+ messages: [
33
+ {
34
+ role: "assistant",
35
+ content: [
36
+ {
37
+ type: "toolCall",
38
+ id: "tooluse_a",
39
+ name: "screenshot",
40
+ arguments: {},
41
+ },
42
+ { type: "toolCall", id: "tooluse_b", name: "bash", arguments: {} },
43
+ ],
44
+ api: "test",
45
+ provider: "test",
46
+ model: "test",
47
+ usage: {},
48
+ stopReason: "toolUse",
49
+ timestamp: 1,
50
+ },
51
+ {
52
+ role: "toolResult",
53
+ toolCallId: "tooluse_a",
54
+ toolName: "screenshot",
55
+ content: [
56
+ { type: "text", text: "screenshot captured" },
57
+ { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
58
+ ],
59
+ isError: false,
60
+ timestamp: 2,
61
+ },
62
+ {
63
+ role: "toolResult",
64
+ toolCallId: "tooluse_b",
65
+ toolName: "bash",
66
+ content: [{ type: "text", text: "command output" }],
67
+ isError: false,
68
+ timestamp: 3,
69
+ },
70
+ ],
71
+ tools: [],
72
+ };
73
+
74
+ const out = piContextToBedrockConverse(context);
75
+ const [assistant, user] = out.messages;
76
+
77
+ check(
78
+ out.messages.length === 2,
79
+ "assistant turn and batched user result turn are emitted",
80
+ );
81
+ check(
82
+ assistant?.content?.map((block) => block.toolUse?.toolUseId).join(",") ===
83
+ "tooluse_a,tooluse_b",
84
+ "assistant contains both toolUse blocks in order",
85
+ );
86
+ check(user?.role === "user", "next message after assistant toolUse is user");
87
+
88
+ const kinds = user?.content?.map((block) => Object.keys(block)[0]) ?? [];
89
+ check(
90
+ kinds.slice(0, 2).join(",") === "toolResult,toolResult",
91
+ "all expected toolResult blocks are the first blocks in the immediate user message",
92
+ );
93
+ check(
94
+ kinds[2] === "image",
95
+ "image content is preserved after the required toolResult prefix",
96
+ );
97
+ check(
98
+ user?.content?.[0]?.toolResult?.toolUseId === "tooluse_a" &&
99
+ user?.content?.[1]?.toolResult?.toolUseId === "tooluse_b",
100
+ "toolResult ids match the assistant toolUse ids in order",
101
+ );
102
+
103
+ if (failures > 0) {
104
+ console.error(
105
+ `\n❌ bedrock tool-result ordering test: ${failures} check(s) failed`,
106
+ );
107
+ process.exit(1);
108
+ }
109
+ console.log("\n✅ bedrock tool-result ordering test passed");
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // Offline regression test for empty/whitespace-only content blocks.
3
+ //
4
+ // Anthropic (via Bedrock Converse) rejects any text content block that is
5
+ // empty or whitespace-only: "messages: text content blocks must contain
6
+ // non-whitespace text". Pi contexts legitimately contain such shapes —
7
+ // errored assistant turns persist with content: [], thinking-only turns
8
+ // carry no text — and the translators used to emit a `{ text: " " }`
9
+ // placeholder for them, which is itself whitespace-only and poisoned every
10
+ // subsequent request in the conversation. The translators must drop those
11
+ // messages (coalescing re-merges the neighbours) and never emit a text block
12
+ // without non-whitespace text. This test makes no network calls.
13
+
14
+ import { pathToFileURL } from "node:url";
15
+ import { join } from "node:path";
16
+
17
+ const ROOT = new URL("..", import.meta.url).pathname;
18
+
19
+ const { piContextToBedrockConverse } = await import(
20
+ pathToFileURL(join(ROOT, "src/translate-foundation-bedrock.ts")).href
21
+ );
22
+ const { piContextToVertexGenerateContent } = await import(
23
+ pathToFileURL(join(ROOT, "src/translate-foundation-vertexai.ts")).href
24
+ );
25
+
26
+ let failures = 0;
27
+ function check(condition, message) {
28
+ if (condition) {
29
+ console.log(` ✓ ${message}`);
30
+ return;
31
+ }
32
+ console.error(` ❌ ${message}`);
33
+ failures++;
34
+ }
35
+
36
+ function bedrockTextBlocks(payload) {
37
+ return payload.messages.flatMap((message) =>
38
+ message.content.flatMap((block) => {
39
+ const texts = [];
40
+ if ("text" in block) texts.push(block.text);
41
+ if ("toolResult" in block) {
42
+ for (const part of block.toolResult.content) {
43
+ if ("text" in part) texts.push(part.text);
44
+ }
45
+ }
46
+ return texts;
47
+ }),
48
+ );
49
+ }
50
+
51
+ function vertexTextParts(payload) {
52
+ return payload.contents.flatMap((content) =>
53
+ content.parts.flatMap((part) => ("text" in part ? [part.text] : [])),
54
+ );
55
+ }
56
+
57
+ const erroredAssistant = {
58
+ role: "assistant",
59
+ content: [],
60
+ stopReason: "error",
61
+ errorMessage: "Model error: 400",
62
+ };
63
+ const thinkingOnlyAssistant = {
64
+ role: "assistant",
65
+ content: [{ type: "thinking", thinking: "planning..." }],
66
+ stopReason: "stop",
67
+ };
68
+
69
+ console.log("Bedrock: history with an errored assistant turn (content: [])");
70
+ {
71
+ const payload = piContextToBedrockConverse({
72
+ systemPrompt: "system",
73
+ messages: [
74
+ { role: "user", content: "hello" },
75
+ erroredAssistant,
76
+ { role: "user", content: "hello again" },
77
+ ],
78
+ });
79
+ check(
80
+ bedrockTextBlocks(payload).every((text) => text.trim().length > 0),
81
+ "no empty/whitespace-only text blocks",
82
+ );
83
+ check(
84
+ payload.messages.length === 1 && payload.messages[0].role === "user",
85
+ "dropped turn's neighbours coalesce into one user message",
86
+ );
87
+ check(
88
+ payload.messages[0].content.length === 2,
89
+ "both user texts survive the coalesce",
90
+ );
91
+ }
92
+
93
+ console.log("Bedrock: thinking-only assistant turn is dropped");
94
+ {
95
+ const payload = piContextToBedrockConverse({
96
+ messages: [
97
+ { role: "user", content: "hello" },
98
+ thinkingOnlyAssistant,
99
+ { role: "user", content: "again" },
100
+ ],
101
+ });
102
+ check(
103
+ payload.messages.every((message) =>
104
+ message.content.every(
105
+ (block) => !("text" in block) || block.text.trim().length > 0,
106
+ ),
107
+ ),
108
+ "no whitespace placeholder emitted",
109
+ );
110
+ }
111
+
112
+ console.log("Bedrock: empty user text parts are filtered, images survive");
113
+ {
114
+ const payload = piContextToBedrockConverse({
115
+ messages: [
116
+ {
117
+ role: "user",
118
+ content: [
119
+ { type: "text", text: "" },
120
+ { type: "image", mimeType: "image/png", data: "aGk=" },
121
+ ],
122
+ },
123
+ ],
124
+ });
125
+ check(payload.messages.length === 1, "image-only user message survives");
126
+ check(
127
+ payload.messages[0].content.every((block) => !("text" in block)),
128
+ "empty text part removed",
129
+ );
130
+ }
131
+
132
+ console.log("Bedrock: whitespace-only turns leave a valid degenerate payload");
133
+ {
134
+ const payload = piContextToBedrockConverse({
135
+ messages: [{ role: "user", content: " " }, erroredAssistant],
136
+ });
137
+ check(payload.messages.length === 1, "one fallback message");
138
+ check(
139
+ payload.messages[0].content[0].text.trim().length > 0,
140
+ "fallback text is non-whitespace",
141
+ );
142
+ }
143
+
144
+ console.log("Bedrock: empty tool-result text gets a non-whitespace fallback");
145
+ {
146
+ const payload = piContextToBedrockConverse({
147
+ messages: [
148
+ { role: "user", content: "run it" },
149
+ {
150
+ role: "assistant",
151
+ content: [
152
+ { type: "toolCall", id: "call_1", name: "tool", arguments: {} },
153
+ ],
154
+ stopReason: "toolUse",
155
+ },
156
+ { role: "toolResult", toolCallId: "call_1", content: [], isError: false },
157
+ ],
158
+ });
159
+ check(
160
+ bedrockTextBlocks(payload).every((text) => text.trim().length > 0),
161
+ "tool_result content text is non-whitespace",
162
+ );
163
+ }
164
+
165
+ console.log("Bedrock: a plain conversation is unchanged");
166
+ {
167
+ const payload = piContextToBedrockConverse({
168
+ systemPrompt: "system",
169
+ messages: [
170
+ { role: "user", content: "hello" },
171
+ {
172
+ role: "assistant",
173
+ content: [{ type: "text", text: "hi there" }],
174
+ stopReason: "stop",
175
+ },
176
+ { role: "user", content: "how are you?" },
177
+ ],
178
+ });
179
+ check(payload.messages.length === 3, "three messages, none dropped");
180
+ check(
181
+ payload.messages.map((message) => message.role).join(",") ===
182
+ "user,assistant,user",
183
+ "role alternation preserved",
184
+ );
185
+ }
186
+
187
+ console.log("Vertex: mirrors the drop behaviour");
188
+ {
189
+ const payload = piContextToVertexGenerateContent({
190
+ systemPrompt: "system",
191
+ messages: [
192
+ { role: "user", content: "hello" },
193
+ erroredAssistant,
194
+ { role: "user", content: "hello again" },
195
+ ],
196
+ });
197
+ check(
198
+ vertexTextParts(payload).every((text) => text.trim().length > 0),
199
+ "no empty/whitespace-only text parts",
200
+ );
201
+ check(
202
+ payload.contents.length === 1 && payload.contents[0].role === "user",
203
+ "dropped turn's neighbours coalesce into one user content",
204
+ );
205
+ }
206
+
207
+ console.log("Vertex: whitespace-only turns leave a valid degenerate payload");
208
+ {
209
+ const payload = piContextToVertexGenerateContent({
210
+ messages: [{ role: "user", content: " " }],
211
+ });
212
+ check(payload.contents.length === 1, "one fallback content entry");
213
+ check(
214
+ payload.contents[0].parts[0].text.trim().length > 0,
215
+ "fallback text is non-whitespace",
216
+ );
217
+ }
218
+
219
+ if (failures > 0) {
220
+ console.error(`\n${failures} check(s) failed`);
221
+ process.exit(1);
222
+ }
223
+ console.log("\nAll checks passed");
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  AssistantMessage,
3
3
  Context,
4
- Message,
5
4
  TextContent,
6
5
  Tool,
7
6
  ToolResultMessage,
@@ -52,58 +51,149 @@ export function piContextToBedrockConverse(context: Context): {
52
51
  toolConfig?: BedrockToolConfig;
53
52
  } {
54
53
  const messages: BedrockConverseMessage[] = [];
54
+ const pi = context.messages;
55
55
 
56
- for (const msg of context.messages) {
57
- const translated = piMessageToBedrockConverse(msg);
58
- if (translated) messages.push(translated);
56
+ for (let i = 0; i < pi.length; i++) {
57
+ const msg = pi[i];
58
+
59
+ if (msg.role === "assistant") {
60
+ const assistant = piAssistantToBedrockConverse(msg);
61
+ // Errored turns (content: []) and thinking-only turns translate to
62
+ // nothing Anthropic accepts — drop them; coalescing re-merges the
63
+ // surrounding user messages. See piAssistantToBedrockConverse.
64
+ if (!assistant) continue;
65
+ const toolUseIds = assistant.content.flatMap((block) =>
66
+ "toolUse" in block ? [block.toolUse.toolUseId] : [],
67
+ );
68
+ if (toolUseIds.length === 0) {
69
+ messages.push(assistant);
70
+ continue;
71
+ }
72
+
73
+ messages.push(assistant);
74
+
75
+ // Bedrock/Anthropic require the next user message after assistant
76
+ // toolUse blocks to begin with the corresponding toolResult blocks,
77
+ // one per toolUseId. Pi stores tool results as separate top-level
78
+ // messages, and screenshot tool results may contain images. If those
79
+ // image blocks are interleaved between toolResult blocks after role
80
+ // coalescing, Anthropic rejects the request with:
81
+ // "tool_use ids were found without tool_result blocks immediately after".
82
+ // Batch all contiguous expected tool results first, then append any
83
+ // images/orphaned result transcript content after the required prefix.
84
+ const expected = new Set(toolUseIds);
85
+ const byId = new Map<string, BedrockToolResultParts>();
86
+ const orphaned: BedrockConverseContentBlock[] = [];
87
+
88
+ let j = i + 1;
89
+ while (j < pi.length) {
90
+ const toolResult = pi[j];
91
+ if (toolResult.role !== "toolResult") break;
92
+ if (
93
+ expected.has(toolResult.toolCallId) &&
94
+ !byId.has(toolResult.toolCallId)
95
+ ) {
96
+ byId.set(
97
+ toolResult.toolCallId,
98
+ piToolResultToBedrockContentParts(toolResult),
99
+ );
100
+ } else {
101
+ orphaned.push(...piToolResultToSyntheticUserContent(toolResult));
102
+ }
103
+ j++;
104
+ }
105
+
106
+ const content: BedrockConverseContentBlock[] = [];
107
+ const images: BedrockConverseContentBlock[] = [];
108
+ for (const id of toolUseIds) {
109
+ const translated = byId.get(id);
110
+ if (translated) {
111
+ content.push(translated.toolResult);
112
+ images.push(...translated.images);
113
+ } else {
114
+ content.push(missingToolResultBlock(id));
115
+ }
116
+ }
117
+ messages.push({
118
+ role: "user",
119
+ content: [...content, ...images, ...orphaned],
120
+ });
121
+ i = j - 1;
122
+ continue;
123
+ }
124
+
125
+ if (msg.role === "toolResult") {
126
+ // A standalone toolResult block is invalid in Bedrock/Anthropic. Keep
127
+ // the information available to the model as normal user-visible text.
128
+ messages.push({
129
+ role: "user",
130
+ content: piToolResultToSyntheticUserContent(msg),
131
+ });
132
+ continue;
133
+ }
134
+
135
+ const user = piUserToBedrockConverse(msg);
136
+ if (user) messages.push(user);
59
137
  }
60
138
 
61
139
  const tools = (context.tools ?? []).map(piToolToBedrockToolSpec);
140
+ const coalesced = coalesceAdjacentMessages(messages);
62
141
  return {
63
- ...(context.systemPrompt ? { system: [{ text: context.systemPrompt }] } : {}),
64
- messages: coalesceAdjacentMessages(messages),
142
+ ...(context.systemPrompt
143
+ ? { system: [{ text: context.systemPrompt }] }
144
+ : {}),
145
+ // Degenerate guard: if every message was dropped as empty, Bedrock still
146
+ // requires at least one message.
147
+ messages:
148
+ coalesced.length > 0
149
+ ? coalesced
150
+ : [{ role: "user", content: [{ text: "(no conversation content)" }] }],
65
151
  ...(tools.length > 0 ? { toolConfig: { tools } } : {}),
66
152
  };
67
153
  }
68
154
 
69
- function piMessageToBedrockConverse(
70
- msg: Message,
155
+ // Anthropic (via Bedrock Converse) rejects any text content block that is
156
+ // empty or whitespace-only: "messages: text content blocks must contain
157
+ // non-whitespace text". Pi contexts legitimately contain such blocks — errored
158
+ // assistant turns persist with content: [], thinking-only turns carry no text,
159
+ // and replayed histories can hold empty text parts — so both translators drop
160
+ // them (returning undefined for messages left with no content) instead of
161
+ // emitting the old `{ text: " " }` placeholder, which was itself
162
+ // whitespace-only and poisoned every request in the conversation.
163
+ function piUserToBedrockConverse(
164
+ msg: UserMessage,
71
165
  ): BedrockConverseMessage | undefined {
72
- switch (msg.role) {
73
- case "user":
74
- return piUserToBedrockConverse(msg);
75
- case "assistant":
76
- return piAssistantToBedrockConverse(msg);
77
- case "toolResult":
78
- return piToolResultToBedrockConverse(msg);
79
- }
80
- }
81
-
82
- function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
83
166
  if (typeof msg.content === "string") {
167
+ if (msg.content.trim().length === 0) return undefined;
84
168
  return { role: "user", content: [{ text: msg.content }] };
85
169
  }
86
170
 
87
- const content = msg.content.map((part): BedrockConverseContentBlock => {
88
- if (part.type === "text") return { text: part.text };
89
- return {
90
- image: {
91
- format: imageFormatFromMimeType(part.mimeType),
92
- source: { bytes: part.data },
93
- },
94
- };
95
- });
171
+ const content = msg.content.flatMap(
172
+ (part): BedrockConverseContentBlock[] => {
173
+ if (part.type === "text") {
174
+ return part.text.trim().length > 0 ? [{ text: part.text }] : [];
175
+ }
176
+ return [
177
+ {
178
+ image: {
179
+ format: imageFormatFromMimeType(part.mimeType),
180
+ source: { bytes: part.data },
181
+ },
182
+ },
183
+ ];
184
+ },
185
+ );
96
186
 
97
- return { role: "user", content: content.length > 0 ? content : [{ text: " " }] };
187
+ return content.length > 0 ? { role: "user", content } : undefined;
98
188
  }
99
189
 
100
190
  function piAssistantToBedrockConverse(
101
191
  msg: AssistantMessage,
102
- ): BedrockConverseMessage {
192
+ ): BedrockConverseMessage | undefined {
103
193
  const content: BedrockConverseContentBlock[] = [];
104
194
 
105
195
  for (const block of msg.content) {
106
- if (block.type === "text" && block.text) {
196
+ if (block.type === "text" && block.text.trim().length > 0) {
107
197
  content.push({ text: block.text });
108
198
  } else if (block.type === "toolCall") {
109
199
  content.push({
@@ -116,31 +206,66 @@ function piAssistantToBedrockConverse(
116
206
  }
117
207
  }
118
208
 
209
+ return content.length > 0 ? { role: "assistant", content } : undefined;
210
+ }
211
+
212
+ type BedrockToolResultParts = {
213
+ toolResult: BedrockConverseContentBlock;
214
+ images: BedrockConverseContentBlock[];
215
+ };
216
+
217
+ function piToolResultToBedrockContentParts(
218
+ msg: ToolResultMessage,
219
+ ): BedrockToolResultParts {
119
220
  return {
120
- role: "assistant",
121
- content: content.length > 0 ? content : [{ text: " " }],
221
+ toolResult: bedrockToolResultBlock(
222
+ msg.toolCallId,
223
+ // Non-whitespace fallback: Anthropic's text-block validation applies
224
+ // inside tool_result content too.
225
+ toolResultText(msg).trim() || "(empty result)",
226
+ msg.isError,
227
+ ),
228
+ images: toolResultImagesAsUserContent(msg),
122
229
  };
123
230
  }
124
231
 
125
- function piToolResultToBedrockConverse(
126
- msg: ToolResultMessage,
127
- ): BedrockConverseMessage {
128
- const text = toolResultText(msg) || " ";
232
+ function missingToolResultBlock(
233
+ toolUseId: string,
234
+ ): BedrockConverseContentBlock {
235
+ return bedrockToolResultBlock(
236
+ toolUseId,
237
+ `Tool result missing for ${toolUseId}.`,
238
+ true,
239
+ );
240
+ }
241
+
242
+ function bedrockToolResultBlock(
243
+ toolUseId: string,
244
+ text: string,
245
+ isError: boolean,
246
+ ): BedrockConverseContentBlock {
129
247
  return {
130
- role: "user",
131
- content: [
132
- {
133
- toolResult: {
134
- toolUseId: msg.toolCallId,
135
- content: [{ text }],
136
- status: msg.isError ? "error" : "success",
137
- },
138
- },
139
- ...toolResultImagesAsUserContent(msg),
140
- ],
248
+ toolResult: {
249
+ toolUseId,
250
+ content: [{ text }],
251
+ status: isError ? "error" : "success",
252
+ },
141
253
  };
142
254
  }
143
255
 
256
+ function piToolResultToSyntheticUserContent(
257
+ msg: ToolResultMessage,
258
+ ): BedrockConverseContentBlock[] {
259
+ return [
260
+ {
261
+ text:
262
+ `Tool result for ${msg.toolName} (${msg.toolCallId})` +
263
+ `${msg.isError ? " failed" : ""}:\n${toolResultText(msg) || " "}`,
264
+ },
265
+ ...toolResultImagesAsUserContent(msg),
266
+ ];
267
+ }
268
+
144
269
  function toolResultText(msg: ToolResultMessage): string {
145
270
  return msg.content
146
271
  .filter((part): part is TextContent => part.type === "text")
@@ -167,13 +292,20 @@ function toolResultImagesAsUserContent(
167
292
  function imageFormatFromMimeType(mimeType: string): string {
168
293
  const format = mimeType.split("/")[1]?.toLowerCase();
169
294
  if (format === "jpg") return "jpeg";
170
- if (format === "jpeg" || format === "png" || format === "gif" || format === "webp") {
295
+ if (
296
+ format === "jpeg" ||
297
+ format === "png" ||
298
+ format === "gif" ||
299
+ format === "webp"
300
+ ) {
171
301
  return format;
172
302
  }
173
303
  return "png";
174
304
  }
175
305
 
176
- function piToolToBedrockToolSpec(tool: Tool): BedrockToolConfig["tools"][number] {
306
+ function piToolToBedrockToolSpec(
307
+ tool: Tool,
308
+ ): BedrockToolConfig["tools"][number] {
177
309
  return {
178
310
  toolSpec: {
179
311
  name: tool.name,
@@ -50,7 +50,12 @@ export function piContextToVertexGenerateContent(context: Context): {
50
50
  ...(context.systemPrompt
51
51
  ? { systemInstruction: { parts: [{ text: context.systemPrompt }] } }
52
52
  : {}),
53
- contents: coalesceAdjacentContents(contents),
53
+ // Degenerate guard: if every message was dropped as empty, Gemini still
54
+ // requires at least one content entry.
55
+ contents:
56
+ contents.length > 0
57
+ ? coalesceAdjacentContents(contents)
58
+ : [{ role: "user", parts: [{ text: "(no conversation content)" }] }],
54
59
  ...(functionDeclarations.length > 0
55
60
  ? { tools: [{ functionDeclarations }] }
56
61
  : {}),
@@ -68,23 +73,34 @@ function piMessageToVertexContent(msg: Message): VertexContent | undefined {
68
73
  }
69
74
  }
70
75
 
71
- function piUserToVertexContent(msg: UserMessage): VertexContent {
76
+ // Mirrors the Bedrock translator: drop empty/whitespace-only text parts and
77
+ // messages left with no parts (errored turns persist with content: [],
78
+ // thinking-only turns carry no text) instead of emitting a whitespace
79
+ // placeholder. See translate-foundation-bedrock.ts for the Anthropic
80
+ // rejection this class of payload causes; Gemini gets the same treatment for
81
+ // symmetry and to avoid burning tokens on placeholder turns.
82
+ function piUserToVertexContent(msg: UserMessage): VertexContent | undefined {
72
83
  if (typeof msg.content === "string") {
84
+ if (msg.content.trim().length === 0) return undefined;
73
85
  return { role: "user", parts: [{ text: msg.content }] };
74
86
  }
75
87
 
76
- const parts = msg.content.map((part): VertexPart => {
77
- if (part.type === "text") return { text: part.text };
78
- return { inlineData: { mimeType: part.mimeType, data: part.data } };
88
+ const parts = msg.content.flatMap((part): VertexPart[] => {
89
+ if (part.type === "text") {
90
+ return part.text.trim().length > 0 ? [{ text: part.text }] : [];
91
+ }
92
+ return [{ inlineData: { mimeType: part.mimeType, data: part.data } }];
79
93
  });
80
94
 
81
- return { role: "user", parts: parts.length > 0 ? parts : [{ text: " " }] };
95
+ return parts.length > 0 ? { role: "user", parts } : undefined;
82
96
  }
83
97
 
84
- function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
98
+ function piAssistantToVertexContent(
99
+ msg: AssistantMessage,
100
+ ): VertexContent | undefined {
85
101
  const parts: VertexPart[] = [];
86
102
  for (const block of msg.content) {
87
- if (block.type === "text" && block.text) {
103
+ if (block.type === "text" && block.text.trim().length > 0) {
88
104
  parts.push({ text: block.text });
89
105
  } else if (block.type === "toolCall") {
90
106
  parts.push({
@@ -98,7 +114,7 @@ function piAssistantToVertexContent(msg: AssistantMessage): VertexContent {
98
114
  });
99
115
  }
100
116
  }
101
- return { role: "model", parts: parts.length > 0 ? parts : [{ text: " " }] };
117
+ return parts.length > 0 ? { role: "model", parts } : undefined;
102
118
  }
103
119
 
104
120
  function piToolResultToVertexContent(msg: ToolResultMessage): VertexContent {