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.
|
|
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.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
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 (
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
):
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
77
|
-
if (part.type === "text")
|
|
78
|
-
|
|
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
|
|
95
|
+
return parts.length > 0 ? { role: "user", parts } : undefined;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
function piAssistantToVertexContent(
|
|
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
|
|
117
|
+
return parts.length > 0 ? { role: "model", parts } : undefined;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
function piToolResultToVertexContent(msg: ToolResultMessage): VertexContent {
|