pi-sap-aicore 0.3.4 → 0.3.5
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 +15 -0
- package/package.json +2 -2
- package/scripts/test-bedrock-tool-results.mjs +109 -0
- package/src/translate-foundation-bedrock.ts +143 -35
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.5] - 2026-07-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Direct AWS Bedrock (Anthropic/Claude) foundation route now batches all
|
|
15
|
+
corresponding `toolResult` blocks immediately after assistant `toolUse` blocks
|
|
16
|
+
before appending screenshot/image content. This fixes Anthropic HTTP 400
|
|
17
|
+
failures from Opus 4.8 such as "`tool_use` ids were found without
|
|
18
|
+
`tool_result` blocks immediately after" on tool-heavy coding-agent turns.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added `scripts/test-bedrock-tool-results.mjs`, a credential-free regression
|
|
23
|
+
test for Bedrock/Anthropic tool-result ordering, and wired it into `npm test`.
|
|
24
|
+
|
|
10
25
|
## [0.3.4] - 2026-07-01
|
|
11
26
|
|
|
12
27
|
### 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.5",
|
|
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",
|
|
35
35
|
"validate:foundation": "node scripts/validate-foundation-executables.mjs",
|
|
36
36
|
"prepublishOnly": "tsc --noEmit && npm test"
|
|
37
37
|
},
|
|
@@ -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");
|
|
@@ -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,33 +51,96 @@ 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
|
+
const toolUseIds = assistant.content.flatMap((block) =>
|
|
62
|
+
"toolUse" in block ? [block.toolUse.toolUseId] : [],
|
|
63
|
+
);
|
|
64
|
+
if (toolUseIds.length === 0) {
|
|
65
|
+
messages.push(assistant);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
messages.push(assistant);
|
|
70
|
+
|
|
71
|
+
// Bedrock/Anthropic require the next user message after assistant
|
|
72
|
+
// toolUse blocks to begin with the corresponding toolResult blocks,
|
|
73
|
+
// one per toolUseId. Pi stores tool results as separate top-level
|
|
74
|
+
// messages, and screenshot tool results may contain images. If those
|
|
75
|
+
// image blocks are interleaved between toolResult blocks after role
|
|
76
|
+
// coalescing, Anthropic rejects the request with:
|
|
77
|
+
// "tool_use ids were found without tool_result blocks immediately after".
|
|
78
|
+
// Batch all contiguous expected tool results first, then append any
|
|
79
|
+
// images/orphaned result transcript content after the required prefix.
|
|
80
|
+
const expected = new Set(toolUseIds);
|
|
81
|
+
const byId = new Map<string, BedrockToolResultParts>();
|
|
82
|
+
const orphaned: BedrockConverseContentBlock[] = [];
|
|
83
|
+
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < pi.length) {
|
|
86
|
+
const toolResult = pi[j];
|
|
87
|
+
if (toolResult.role !== "toolResult") break;
|
|
88
|
+
if (
|
|
89
|
+
expected.has(toolResult.toolCallId) &&
|
|
90
|
+
!byId.has(toolResult.toolCallId)
|
|
91
|
+
) {
|
|
92
|
+
byId.set(
|
|
93
|
+
toolResult.toolCallId,
|
|
94
|
+
piToolResultToBedrockContentParts(toolResult),
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
orphaned.push(...piToolResultToSyntheticUserContent(toolResult));
|
|
98
|
+
}
|
|
99
|
+
j++;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content: BedrockConverseContentBlock[] = [];
|
|
103
|
+
const images: BedrockConverseContentBlock[] = [];
|
|
104
|
+
for (const id of toolUseIds) {
|
|
105
|
+
const translated = byId.get(id);
|
|
106
|
+
if (translated) {
|
|
107
|
+
content.push(translated.toolResult);
|
|
108
|
+
images.push(...translated.images);
|
|
109
|
+
} else {
|
|
110
|
+
content.push(missingToolResultBlock(id));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
messages.push({
|
|
114
|
+
role: "user",
|
|
115
|
+
content: [...content, ...images, ...orphaned],
|
|
116
|
+
});
|
|
117
|
+
i = j - 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (msg.role === "toolResult") {
|
|
122
|
+
// A standalone toolResult block is invalid in Bedrock/Anthropic. Keep
|
|
123
|
+
// the information available to the model as normal user-visible text.
|
|
124
|
+
messages.push({
|
|
125
|
+
role: "user",
|
|
126
|
+
content: piToolResultToSyntheticUserContent(msg),
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
messages.push(piUserToBedrockConverse(msg));
|
|
59
132
|
}
|
|
60
133
|
|
|
61
134
|
const tools = (context.tools ?? []).map(piToolToBedrockToolSpec);
|
|
62
135
|
return {
|
|
63
|
-
...(context.systemPrompt
|
|
136
|
+
...(context.systemPrompt
|
|
137
|
+
? { system: [{ text: context.systemPrompt }] }
|
|
138
|
+
: {}),
|
|
64
139
|
messages: coalesceAdjacentMessages(messages),
|
|
65
140
|
...(tools.length > 0 ? { toolConfig: { tools } } : {}),
|
|
66
141
|
};
|
|
67
142
|
}
|
|
68
143
|
|
|
69
|
-
function piMessageToBedrockConverse(
|
|
70
|
-
msg: Message,
|
|
71
|
-
): 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
144
|
function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
83
145
|
if (typeof msg.content === "string") {
|
|
84
146
|
return { role: "user", content: [{ text: msg.content }] };
|
|
@@ -94,7 +156,10 @@ function piUserToBedrockConverse(msg: UserMessage): BedrockConverseMessage {
|
|
|
94
156
|
};
|
|
95
157
|
});
|
|
96
158
|
|
|
97
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
role: "user",
|
|
161
|
+
content: content.length > 0 ? content : [{ text: " " }],
|
|
162
|
+
};
|
|
98
163
|
}
|
|
99
164
|
|
|
100
165
|
function piAssistantToBedrockConverse(
|
|
@@ -122,25 +187,61 @@ function piAssistantToBedrockConverse(
|
|
|
122
187
|
};
|
|
123
188
|
}
|
|
124
189
|
|
|
125
|
-
|
|
190
|
+
type BedrockToolResultParts = {
|
|
191
|
+
toolResult: BedrockConverseContentBlock;
|
|
192
|
+
images: BedrockConverseContentBlock[];
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function piToolResultToBedrockContentParts(
|
|
126
196
|
msg: ToolResultMessage,
|
|
127
|
-
):
|
|
128
|
-
const text = toolResultText(msg) || " ";
|
|
197
|
+
): BedrockToolResultParts {
|
|
129
198
|
return {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
199
|
+
toolResult: bedrockToolResultBlock(
|
|
200
|
+
msg.toolCallId,
|
|
201
|
+
toolResultText(msg) || " ",
|
|
202
|
+
msg.isError,
|
|
203
|
+
),
|
|
204
|
+
images: toolResultImagesAsUserContent(msg),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function missingToolResultBlock(
|
|
209
|
+
toolUseId: string,
|
|
210
|
+
): BedrockConverseContentBlock {
|
|
211
|
+
return bedrockToolResultBlock(
|
|
212
|
+
toolUseId,
|
|
213
|
+
`Tool result missing for ${toolUseId}.`,
|
|
214
|
+
true,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function bedrockToolResultBlock(
|
|
219
|
+
toolUseId: string,
|
|
220
|
+
text: string,
|
|
221
|
+
isError: boolean,
|
|
222
|
+
): BedrockConverseContentBlock {
|
|
223
|
+
return {
|
|
224
|
+
toolResult: {
|
|
225
|
+
toolUseId,
|
|
226
|
+
content: [{ text }],
|
|
227
|
+
status: isError ? "error" : "success",
|
|
228
|
+
},
|
|
141
229
|
};
|
|
142
230
|
}
|
|
143
231
|
|
|
232
|
+
function piToolResultToSyntheticUserContent(
|
|
233
|
+
msg: ToolResultMessage,
|
|
234
|
+
): BedrockConverseContentBlock[] {
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
text:
|
|
238
|
+
`Tool result for ${msg.toolName} (${msg.toolCallId})` +
|
|
239
|
+
`${msg.isError ? " failed" : ""}:\n${toolResultText(msg) || " "}`,
|
|
240
|
+
},
|
|
241
|
+
...toolResultImagesAsUserContent(msg),
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
144
245
|
function toolResultText(msg: ToolResultMessage): string {
|
|
145
246
|
return msg.content
|
|
146
247
|
.filter((part): part is TextContent => part.type === "text")
|
|
@@ -167,13 +268,20 @@ function toolResultImagesAsUserContent(
|
|
|
167
268
|
function imageFormatFromMimeType(mimeType: string): string {
|
|
168
269
|
const format = mimeType.split("/")[1]?.toLowerCase();
|
|
169
270
|
if (format === "jpg") return "jpeg";
|
|
170
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
format === "jpeg" ||
|
|
273
|
+
format === "png" ||
|
|
274
|
+
format === "gif" ||
|
|
275
|
+
format === "webp"
|
|
276
|
+
) {
|
|
171
277
|
return format;
|
|
172
278
|
}
|
|
173
279
|
return "png";
|
|
174
280
|
}
|
|
175
281
|
|
|
176
|
-
function piToolToBedrockToolSpec(
|
|
282
|
+
function piToolToBedrockToolSpec(
|
|
283
|
+
tool: Tool,
|
|
284
|
+
): BedrockToolConfig["tools"][number] {
|
|
177
285
|
return {
|
|
178
286
|
toolSpec: {
|
|
179
287
|
name: tool.name,
|