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 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.4",
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 (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
+ 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 ? { system: [{ text: 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 { role: "user", content: content.length > 0 ? content : [{ text: " " }] };
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
- function piToolResultToBedrockConverse(
190
+ type BedrockToolResultParts = {
191
+ toolResult: BedrockConverseContentBlock;
192
+ images: BedrockConverseContentBlock[];
193
+ };
194
+
195
+ function piToolResultToBedrockContentParts(
126
196
  msg: ToolResultMessage,
127
- ): BedrockConverseMessage {
128
- const text = toolResultText(msg) || " ";
197
+ ): BedrockToolResultParts {
129
198
  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
- ],
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 (format === "jpeg" || format === "png" || format === "gif" || format === "webp") {
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(tool: Tool): BedrockToolConfig["tools"][number] {
282
+ function piToolToBedrockToolSpec(
283
+ tool: Tool,
284
+ ): BedrockToolConfig["tools"][number] {
177
285
  return {
178
286
  toolSpec: {
179
287
  name: tool.name,