@loopingai/core 0.5.1 → 0.6.0

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.
Files changed (88) hide show
  1. package/README.md +6 -4
  2. package/dist/a2a/notify.d.ts +4 -3
  3. package/dist/a2a/notify.js +4 -3
  4. package/dist/agent/anthropic/index.d.ts +15 -0
  5. package/dist/agent/anthropic/index.js +19 -0
  6. package/dist/agent/anthropic/language-model.d.ts +59 -0
  7. package/dist/agent/anthropic/language-model.js +442 -0
  8. package/dist/agent/anthropic/prompt.d.ts +84 -0
  9. package/dist/agent/anthropic/prompt.js +541 -0
  10. package/dist/agent/anthropic/runtime.d.ts +79 -0
  11. package/dist/agent/anthropic/runtime.js +130 -0
  12. package/dist/agent/control.js +10 -9
  13. package/dist/agent/errors.d.ts +85 -0
  14. package/dist/agent/errors.js +64 -0
  15. package/dist/agent/final-reply.d.ts +14 -13
  16. package/dist/agent/final-reply.js +28 -11
  17. package/dist/agent/history.d.ts +3 -3
  18. package/dist/agent/history.js +2 -2
  19. package/dist/agent/index.d.ts +4 -2
  20. package/dist/agent/index.js +4 -2
  21. package/dist/agent/inference.d.ts +58 -1
  22. package/dist/agent/inference.js +44 -0
  23. package/dist/agent/model.d.ts +42 -25
  24. package/dist/agent/model.js +1 -48
  25. package/dist/agent/session.d.ts +6 -7
  26. package/dist/agent/session.js +3 -3
  27. package/dist/agent/workers-ai/index.d.ts +23 -0
  28. package/dist/agent/workers-ai/index.js +23 -0
  29. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  30. package/dist/agent/workers-ai/runtime.js +63 -0
  31. package/dist/config.d.ts +49 -15
  32. package/dist/config.js +30 -1
  33. package/dist/contract/plugin.d.ts +63 -3
  34. package/dist/contract/plugin.js +76 -0
  35. package/dist/contract/recipe.d.ts +16 -17
  36. package/dist/db/db.d.ts +0 -1
  37. package/dist/db/migrations/index.js +8 -1
  38. package/dist/db/models/subtasks.d.ts +24 -25
  39. package/dist/db/models/subtasks.js +33 -76
  40. package/dist/db/schema.d.ts +2 -21
  41. package/dist/db/schema.js +2 -4
  42. package/dist/host/agent.d.ts +58 -4
  43. package/dist/host/agent.js +63 -9
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +2 -2
  46. package/dist/platform.d.ts +74 -11
  47. package/dist/platform.js +76 -13
  48. package/dist/round/agent.d.ts +36 -31
  49. package/dist/round/agent.js +61 -89
  50. package/dist/round/index.d.ts +3 -2
  51. package/dist/round/index.js +2 -2
  52. package/dist/round/policy.d.ts +2 -2
  53. package/dist/round/subagent.d.ts +19 -1
  54. package/dist/round/subagent.js +22 -5
  55. package/dist/round/turn.d.ts +32 -13
  56. package/dist/round/turn.js +83 -16
  57. package/dist/round/workflow.d.ts +23 -7
  58. package/dist/round/workflow.js +132 -65
  59. package/dist/runtime/index.d.ts +4 -2
  60. package/dist/runtime/index.js +6 -0
  61. package/dist/subagent/fingerprint.d.ts +2 -2
  62. package/dist/subagent/fingerprint.js +8 -17
  63. package/dist/subagent/index.d.ts +6 -4
  64. package/dist/subagent/index.js +8 -6
  65. package/dist/subagent/prompt.d.ts +4 -5
  66. package/dist/subagent/prompt.js +0 -8
  67. package/dist/subagent/run.d.ts +8 -1
  68. package/dist/subagent/run.js +59 -9
  69. package/dist/subtasks/catalog.d.ts +1 -1
  70. package/dist/subtasks/catalog.js +1 -1
  71. package/dist/subtasks/decomposition.d.ts +16 -20
  72. package/dist/subtasks/decomposition.js +27 -75
  73. package/dist/subtasks/delegate.d.ts +20 -1
  74. package/dist/subtasks/delegate.js +21 -16
  75. package/dist/subtasks/index.d.ts +1 -2
  76. package/dist/subtasks/index.js +1 -2
  77. package/dist/subtasks/subtask-types.d.ts +0 -8
  78. package/dist/subtasks/subtask-types.js +0 -7
  79. package/dist/subtasks/types.d.ts +45 -70
  80. package/dist/testing/mock-model.d.ts +35 -0
  81. package/dist/testing/mock-model.js +75 -0
  82. package/dist/testing/vcr-global-setup.d.ts +1 -3
  83. package/dist/testing/vcr-global-setup.js +1 -3
  84. package/dist/worker/index.d.ts +5 -12
  85. package/dist/worker/index.js +5 -12
  86. package/package.json +19 -1
  87. package/dist/subtasks/scheduler.d.ts +0 -48
  88. package/dist/subtasks/scheduler.js +0 -47
@@ -0,0 +1,84 @@
1
+ import type Anthropic from "@anthropic-ai/sdk";
2
+ import type { LanguageModelV4CallOptions, LanguageModelV4FunctionTool, LanguageModelV4Prompt, LanguageModelV4ProviderTool, LanguageModelV4ToolChoice, SharedV4Warning } from "@ai-sdk/provider";
3
+ /**
4
+ * The AI SDK prompt → Anthropic Messages request mapping.
5
+ *
6
+ * Split out from the model itself because it is pure: prompt in, request body
7
+ * plus warnings out, no client and no I/O. That is what makes the sharp edges
8
+ * below testable without a network or a cassette, and every one of them is a
9
+ * silent-corruption bug rather than a loud one.
10
+ *
11
+ * Three shape mismatches do the real damage if you get them wrong:
12
+ *
13
+ * 1. **There is no `tool` role in the Messages API.** The AI SDK models tool
14
+ * results as their own role; Anthropic carries them as `tool_result` blocks
15
+ * inside a **user** turn.
16
+ * 2. **`thinking` blocks are signed and must be replayed byte-for-byte.** Opus 5
17
+ * runs adaptive thinking by default and rejects a modified block. Core's loop
18
+ * is multi-turn tool use on every round, so it replays the previous assistant
19
+ * message every single time — drop or rebuild the signature and round two of
20
+ * every task 400s.
21
+ * 3. **`input` is a parsed value on the way in and a JSON string on the way
22
+ * out.** `LanguageModelV4ToolCallPart.input` (prompt side) is `unknown`;
23
+ * `LanguageModelV4ToolCall.input` (result side) is `string`. Mixing them up
24
+ * type-checks and corrupts silently.
25
+ */
26
+ /** Namespaced provider key for anything we stash on a part. */
27
+ export declare const ANTHROPIC_PROVIDER = "anthropic";
28
+ /**
29
+ * A tool-call id Anthropic will accept, from one it might not.
30
+ *
31
+ * The API validates this field against `^[a-zA-Z0-9_-]+$` and answers a
32
+ * violation with a `400 invalid_request_error` naming the exact message index —
33
+ * `messages.7.content.1.tool_use.id`. That is a *deterministic* failure on
34
+ * replayed history, which makes it the worst kind: every retry and every
35
+ * fallback re-sends the same poisoned message list and fails identically, so the
36
+ * round burns its whole ladder without a single attempt that could have worked.
37
+ * It cost a production task exactly that way.
38
+ *
39
+ * Applied to both sides of the pair — the assistant's `tool_use.id` and the
40
+ * user turn's `tool_result.tool_use_id` — because the two must agree or the
41
+ * request is malformed in a second, more confusing way. Since the mapping is a
42
+ * pure function of the id, applying it independently at both call sites lands on
43
+ * the same answer without threading state between them.
44
+ *
45
+ * This is a **backstop**, not the fix. Core's own generators emit safe ids
46
+ * directly (see {@link file://../../subtasks/delegate.ts delegateToolCallId});
47
+ * this catches the next generator someone writes, and any id that reaches the
48
+ * adapter from outside core.
49
+ *
50
+ * Not injective, and deliberately not made so: two ids differing only in
51
+ * unsafe characters collapse together. Adding a hash to avoid that would change
52
+ * every id to guard against a collision that requires two synthetic calls in one
53
+ * request whose ids differ *only* in punctuation. Provider-legible ids are worth
54
+ * more than that.
55
+ */
56
+ export declare function providerSafeToolCallId(id: string): string;
57
+ /**
58
+ * How long a cache entry should live.
59
+ *
60
+ * `"5m"` is Anthropic's default and suits back-to-back model calls. `"1h"`
61
+ * costs 2x on write instead of 1.25x and exists for agents whose rounds are
62
+ * separated by long tool work — a container boot, an install, a test suite —
63
+ * where a 5-minute entry has expired by the time the next round starts and the
64
+ * whole prefix is re-billed at full price.
65
+ */
66
+ export type CacheTtl = "5m" | "1h";
67
+ export interface PromptMappingOptions {
68
+ /** Cache TTL, or `false` to place no breakpoints at all. */
69
+ cache?: CacheTtl | false;
70
+ /** `max_tokens` when the caller supplied none. Anthropic requires the field. */
71
+ defaultMaxTokens: number;
72
+ }
73
+ export interface MappedPrompt {
74
+ system: Anthropic.TextBlockParam[] | undefined;
75
+ messages: Anthropic.MessageParam[];
76
+ warnings: SharedV4Warning[];
77
+ }
78
+ /** Map the prompt half of a call: system blocks + turns. */
79
+ export declare function mapPrompt(prompt: LanguageModelV4Prompt, options: PromptMappingOptions): MappedPrompt;
80
+ /** Map tool definitions, putting the frozen-prefix breakpoint on the last one. */
81
+ export declare function mapTools(tools: Array<LanguageModelV4FunctionTool | LanguageModelV4ProviderTool>, ttl: CacheTtl | undefined, warnings: SharedV4Warning[]): Anthropic.ToolUnion[] | undefined;
82
+ /** Map tool choice. Note V4 uses `{type:"required"}`, not the bare string. */
83
+ export declare function mapToolChoice(toolChoice: LanguageModelV4ToolChoice | undefined): Anthropic.ToolChoice | undefined;
84
+ export declare function collectSamplingWarnings(options: LanguageModelV4CallOptions): SharedV4Warning[];
@@ -0,0 +1,541 @@
1
+ /**
2
+ * The AI SDK prompt → Anthropic Messages request mapping.
3
+ *
4
+ * Split out from the model itself because it is pure: prompt in, request body
5
+ * plus warnings out, no client and no I/O. That is what makes the sharp edges
6
+ * below testable without a network or a cassette, and every one of them is a
7
+ * silent-corruption bug rather than a loud one.
8
+ *
9
+ * Three shape mismatches do the real damage if you get them wrong:
10
+ *
11
+ * 1. **There is no `tool` role in the Messages API.** The AI SDK models tool
12
+ * results as their own role; Anthropic carries them as `tool_result` blocks
13
+ * inside a **user** turn.
14
+ * 2. **`thinking` blocks are signed and must be replayed byte-for-byte.** Opus 5
15
+ * runs adaptive thinking by default and rejects a modified block. Core's loop
16
+ * is multi-turn tool use on every round, so it replays the previous assistant
17
+ * message every single time — drop or rebuild the signature and round two of
18
+ * every task 400s.
19
+ * 3. **`input` is a parsed value on the way in and a JSON string on the way
20
+ * out.** `LanguageModelV4ToolCallPart.input` (prompt side) is `unknown`;
21
+ * `LanguageModelV4ToolCall.input` (result side) is `string`. Mixing them up
22
+ * type-checks and corrupts silently.
23
+ */
24
+ /** Namespaced provider key for anything we stash on a part. */
25
+ export const ANTHROPIC_PROVIDER = "anthropic";
26
+ /** Anything Anthropic will not accept in a `tool_use.id`. */
27
+ const UNSAFE_TOOL_CALL_ID = /[^a-zA-Z0-9_-]/g;
28
+ /**
29
+ * A tool-call id Anthropic will accept, from one it might not.
30
+ *
31
+ * The API validates this field against `^[a-zA-Z0-9_-]+$` and answers a
32
+ * violation with a `400 invalid_request_error` naming the exact message index —
33
+ * `messages.7.content.1.tool_use.id`. That is a *deterministic* failure on
34
+ * replayed history, which makes it the worst kind: every retry and every
35
+ * fallback re-sends the same poisoned message list and fails identically, so the
36
+ * round burns its whole ladder without a single attempt that could have worked.
37
+ * It cost a production task exactly that way.
38
+ *
39
+ * Applied to both sides of the pair — the assistant's `tool_use.id` and the
40
+ * user turn's `tool_result.tool_use_id` — because the two must agree or the
41
+ * request is malformed in a second, more confusing way. Since the mapping is a
42
+ * pure function of the id, applying it independently at both call sites lands on
43
+ * the same answer without threading state between them.
44
+ *
45
+ * This is a **backstop**, not the fix. Core's own generators emit safe ids
46
+ * directly (see {@link file://../../subtasks/delegate.ts delegateToolCallId});
47
+ * this catches the next generator someone writes, and any id that reaches the
48
+ * adapter from outside core.
49
+ *
50
+ * Not injective, and deliberately not made so: two ids differing only in
51
+ * unsafe characters collapse together. Adding a hash to avoid that would change
52
+ * every id to guard against a collision that requires two synthetic calls in one
53
+ * request whose ids differ *only* in punctuation. Provider-legible ids are worth
54
+ * more than that.
55
+ */
56
+ export function providerSafeToolCallId(id) {
57
+ return id.replace(UNSAFE_TOOL_CALL_ID, "_");
58
+ }
59
+ /**
60
+ * Anthropic's cache marker.
61
+ *
62
+ * `{type:"ephemeral"}` is the *cache-on* switch and the only `cache_control`
63
+ * type there is — "ephemeral" describes the entry's lifetime, not its value.
64
+ * Omitting the field is what disables caching.
65
+ */
66
+ function cacheControl(ttl) {
67
+ return ttl === "1h"
68
+ ? { type: "ephemeral", ttl: "1h" }
69
+ : { type: "ephemeral" };
70
+ }
71
+ /**
72
+ * At most four `cache_control` breakpoints are allowed per request, and they are
73
+ * spent stability-first because the cache is a **prefix** match and the render
74
+ * order is `tools` → `system` → `messages`:
75
+ *
76
+ * - one on the last tool (frozen for the agent's lifetime)
77
+ * - one on the last system block (frozen)
78
+ * - up to two rolling ones in the conversation
79
+ *
80
+ * The second rolling breakpoint is not redundant. Each breakpoint walks back at
81
+ * most **20 content blocks** looking for a live entry, and one busy round of an
82
+ * agent that calls tools in a loop adds a `tool_use`/`tool_result` pair per
83
+ * call — well past 20 in a single turn. Without an anchor partway back, the next
84
+ * request finds nothing, silently misses, and re-bills the entire prefix with no
85
+ * error to notice.
86
+ */
87
+ const MESSAGE_BREAKPOINT_STRIDE = 15;
88
+ /** Which content blocks a `file` part can become, by media type. */
89
+ function fileBlock(data, mediaType, warnings) {
90
+ const unsupported = (details) => {
91
+ warnings.push({ type: "unsupported", feature: "file-part", details });
92
+ return undefined;
93
+ };
94
+ if (data.type === "reference")
95
+ return unsupported("provider file references are not supported");
96
+ if (data.type === "text")
97
+ return { type: "text", text: data.text };
98
+ const isImage = mediaType.startsWith("image/");
99
+ const isPdf = mediaType === "application/pdf";
100
+ if (!isImage && !isPdf)
101
+ return unsupported(`unsupported media type "${mediaType}"`);
102
+ if (data.type === "url") {
103
+ const url = data.url.toString();
104
+ return isImage
105
+ ? { type: "image", source: { type: "url", url } }
106
+ : { type: "document", source: { type: "url", url } };
107
+ }
108
+ // `data` is raw bytes or an already-base64 string. Anthropic wants base64.
109
+ const base64 = typeof data.data === "string" ? data.data : bytesToBase64(data.data);
110
+ return isImage
111
+ ? {
112
+ type: "image",
113
+ source: {
114
+ type: "base64",
115
+ media_type: mediaType,
116
+ data: base64
117
+ }
118
+ }
119
+ : {
120
+ type: "document",
121
+ source: { type: "base64", media_type: "application/pdf", data: base64 }
122
+ };
123
+ }
124
+ /**
125
+ * Base64 without `Buffer`.
126
+ *
127
+ * Core targets workerd, where `Buffer` only exists under `nodejs_compat` and
128
+ * core must not assume a consumer enabled it. Chunked so a large file cannot
129
+ * blow the argument limit of `String.fromCharCode`.
130
+ */
131
+ function bytesToBase64(bytes) {
132
+ let binary = "";
133
+ const chunk = 0x8000;
134
+ for (let i = 0; i < bytes.length; i += chunk) {
135
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
136
+ }
137
+ return btoa(binary);
138
+ }
139
+ /**
140
+ * Read a signature previously stashed on a reasoning part.
141
+ *
142
+ * Written by the response mapper into `providerMetadata.anthropic.signature`;
143
+ * the SDK hands it back as `providerOptions.anthropic.signature`. Both halves
144
+ * have to agree exactly — this is the single most load-bearing line in the file.
145
+ */
146
+ function signatureOf(providerOptions) {
147
+ const own = providerOptions?.[ANTHROPIC_PROVIDER];
148
+ const signature = own?.["signature"];
149
+ return typeof signature === "string" ? signature : undefined;
150
+ }
151
+ /** `redacted_thinking` round-trips as opaque data, same contract as a signature. */
152
+ function redactedDataOf(providerOptions) {
153
+ const own = providerOptions?.[ANTHROPIC_PROVIDER];
154
+ const data = own?.["redactedData"];
155
+ return typeof data === "string" ? data : undefined;
156
+ }
157
+ /** One AI SDK message → zero or more Anthropic messages (before merging). */
158
+ function mapMessage(message, warnings) {
159
+ switch (message.role) {
160
+ case "system":
161
+ // Handled by the caller — system text is a top-level field, not a turn.
162
+ return undefined;
163
+ case "user": {
164
+ const content = [];
165
+ for (const part of message.content) {
166
+ if (part.type === "text") {
167
+ if (part.text.length > 0)
168
+ content.push({ type: "text", text: part.text });
169
+ }
170
+ else {
171
+ const block = fileBlock(part.data, part.mediaType, warnings);
172
+ if (block)
173
+ content.push(block);
174
+ }
175
+ }
176
+ return content.length > 0 ? { role: "user", content } : undefined;
177
+ }
178
+ case "assistant": {
179
+ const content = [];
180
+ for (const part of message.content) {
181
+ switch (part.type) {
182
+ case "text":
183
+ // Anthropic rejects an empty text block; the SDK emits them freely.
184
+ if (part.text.length > 0)
185
+ content.push({ type: "text", text: part.text });
186
+ break;
187
+ case "reasoning": {
188
+ const redactedData = redactedDataOf(part.providerOptions);
189
+ if (redactedData !== undefined) {
190
+ content.push({ type: "redacted_thinking", data: redactedData });
191
+ break;
192
+ }
193
+ const signature = signatureOf(part.providerOptions);
194
+ // A thinking block without its signature is rejected outright, and
195
+ // inventing one is worse. Dropping it is the only safe move — but
196
+ // say so, because it means the response mapper lost the metadata.
197
+ if (signature === undefined) {
198
+ warnings.push({
199
+ type: "other",
200
+ message: "dropped a reasoning part with no Anthropic signature; " +
201
+ "thinking blocks cannot be replayed without one"
202
+ });
203
+ break;
204
+ }
205
+ content.push({ type: "thinking", thinking: part.text, signature });
206
+ break;
207
+ }
208
+ case "tool-call":
209
+ content.push({
210
+ type: "tool_use",
211
+ id: providerSafeToolCallId(part.toolCallId),
212
+ name: part.toolName,
213
+ // Prompt side: already a parsed value. Do NOT stringify.
214
+ input: part.input
215
+ });
216
+ break;
217
+ case "tool-result":
218
+ // Provider-executed results replayed on the assistant turn. Core
219
+ // never produces these; pass through rather than silently drop.
220
+ warnings.push({
221
+ type: "unsupported",
222
+ feature: "assistant tool-result part",
223
+ details: `tool "${part.toolName}" result on an assistant turn was dropped`
224
+ });
225
+ break;
226
+ case "file": {
227
+ const block = fileBlock(part.data, part.mediaType, warnings);
228
+ if (block)
229
+ content.push(block);
230
+ break;
231
+ }
232
+ default:
233
+ warnings.push({
234
+ type: "unsupported",
235
+ feature: `assistant part "${part.type}"`
236
+ });
237
+ }
238
+ }
239
+ return content.length > 0 ? { role: "assistant", content } : undefined;
240
+ }
241
+ case "tool": {
242
+ // The mapping that most often goes wrong: Anthropic has no `tool` role.
243
+ // Tool results ride in a *user* turn as `tool_result` blocks.
244
+ const content = [];
245
+ for (const part of message.content) {
246
+ if (part.type !== "tool-result") {
247
+ warnings.push({
248
+ type: "unsupported",
249
+ feature: `tool part "${part.type}"`
250
+ });
251
+ continue;
252
+ }
253
+ const { text, isError } = toolResultText(part.output);
254
+ content.push({
255
+ type: "tool_result",
256
+ // The same mapping as the `tool_use.id` above, and it has to be: a
257
+ // result whose id no longer matches its call is a malformed request.
258
+ tool_use_id: providerSafeToolCallId(part.toolCallId),
259
+ content: text,
260
+ ...(isError ? { is_error: true } : {})
261
+ });
262
+ }
263
+ return content.length > 0 ? { role: "user", content } : undefined;
264
+ }
265
+ }
266
+ }
267
+ /** Flatten a V4 tool result into the string Anthropic carries, plus its error flag. */
268
+ function toolResultText(output) {
269
+ switch (output.type) {
270
+ case "text":
271
+ return { text: output.value, isError: false };
272
+ case "error-text":
273
+ return { text: output.value, isError: true };
274
+ case "json":
275
+ return { text: JSON.stringify(output.value), isError: false };
276
+ case "error-json":
277
+ return { text: JSON.stringify(output.value), isError: true };
278
+ case "execution-denied":
279
+ return {
280
+ text: output.reason ?? "The tool call was denied.",
281
+ isError: true
282
+ };
283
+ case "content":
284
+ return {
285
+ text: output.value
286
+ .map((part) => (part.type === "text" ? part.text : `[${part.type}]`))
287
+ .join("\n"),
288
+ isError: false
289
+ };
290
+ }
291
+ }
292
+ /**
293
+ * Merge consecutive same-role turns.
294
+ *
295
+ * Cheap insurance rather than a hard requirement: the mapping already produces
296
+ * alternating turns for the shapes core generates (an assistant turn of
297
+ * `tool_use` followed by a `tool` message that becomes a user turn). But a
298
+ * caller is free to emit two user messages in a row, and merging is always
299
+ * valid, so the adapter never has to care which API version tightened the rule.
300
+ */
301
+ function mergeAdjacent(messages) {
302
+ const merged = [];
303
+ for (const message of messages) {
304
+ const previous = merged[merged.length - 1];
305
+ if (previous && previous.role === message.role) {
306
+ previous.content = hoistToolResults([
307
+ ...previous.content,
308
+ ...message.content
309
+ ]);
310
+ continue;
311
+ }
312
+ merged.push({
313
+ role: message.role,
314
+ content: [...message.content]
315
+ });
316
+ }
317
+ return merged;
318
+ }
319
+ /**
320
+ * `tool_result` blocks must lead the user turn that answers a `tool_use`.
321
+ *
322
+ * Only reachable via a merge — the mapping alone never mixes them with text —
323
+ * but a caller that puts a user message between an assistant's tool call and its
324
+ * results would otherwise produce `[text, tool_result]`, which the API rejects.
325
+ * Stable within each group, so the pairing with the preceding `tool_use` blocks
326
+ * is preserved.
327
+ */
328
+ function hoistToolResults(content) {
329
+ const results = content.filter((block) => block.type === "tool_result");
330
+ if (results.length === 0 || results.length === content.length)
331
+ return content;
332
+ return [
333
+ ...results,
334
+ ...content.filter((block) => block.type !== "tool_result")
335
+ ];
336
+ }
337
+ /**
338
+ * Give every `tool_use` a `tool_result`, inventing one where the transcript
339
+ * lost it.
340
+ *
341
+ * Anthropic rejects a request in which an assistant turn calls a tool and the
342
+ * next user turn does not answer it — every id, no exceptions. The mapping above
343
+ * can produce exactly that without anything looking wrong: a `tool` message
344
+ * whose parts were all unmappable maps to `undefined` and is dropped, taking the
345
+ * only `tool_result` for a still-present `tool_use` with it.
346
+ *
347
+ * Synthesising an error result is strictly better than the alternatives. Dropping
348
+ * the `tool_use` too would rewrite what the model actually did, and a `thinking`
349
+ * block signed over that turn would then no longer match. Saying "the result was
350
+ * lost" is true, is what the model would infer anyway, and keeps the turn
351
+ * replayable.
352
+ *
353
+ * **Scope: only a turn with something after it.** A conversation *ending* on an
354
+ * unanswered `tool_use` is a different shape — an assistant prefill — and core
355
+ * never produces one, since `generateText` appends results before the next call.
356
+ * Repairing it would mean appending a turn to a prompt the caller deliberately
357
+ * ended, on a guess about how the API treats it. Left alone until there is a
358
+ * reason to touch it.
359
+ */
360
+ function answerOrphanedToolUses(messages, warnings) {
361
+ const out = [];
362
+ for (let i = 0; i < messages.length; i += 1) {
363
+ const message = messages[i];
364
+ out.push(message);
365
+ if (message.role !== "assistant")
366
+ continue;
367
+ const blocks = message.content;
368
+ const ids = blocks
369
+ .filter((b) => b.type === "tool_use")
370
+ .map((b) => b.id);
371
+ if (ids.length === 0)
372
+ continue;
373
+ const next = messages[i + 1];
374
+ if (!next)
375
+ continue;
376
+ const answered = new Set(next.role === "user"
377
+ ? next.content
378
+ .filter((b) => b.type === "tool_result")
379
+ .map((b) => b.tool_use_id)
380
+ : []);
381
+ const missing = ids.filter((id) => !answered.has(id));
382
+ if (missing.length === 0)
383
+ continue;
384
+ warnings.push({
385
+ type: "other",
386
+ message: `${missing.length} tool call(s) had no result in the prompt; ` +
387
+ "an error result was synthesised so the request stays valid"
388
+ });
389
+ const repairs = missing.map((id) => ({
390
+ type: "tool_result",
391
+ tool_use_id: id,
392
+ content: "The result of this tool call was not preserved.",
393
+ is_error: true
394
+ }));
395
+ // Prepended, not appended: `tool_result` blocks must lead their turn, the
396
+ // same rule `hoistToolResults` enforces after a merge.
397
+ if (next.role === "user") {
398
+ next.content = [
399
+ ...repairs,
400
+ ...next.content
401
+ ];
402
+ }
403
+ else {
404
+ out.push({ role: "user", content: repairs });
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+ /**
410
+ * Place the rolling conversation breakpoints.
411
+ *
412
+ * Walks the flattened block sequence and marks the last block, plus one
413
+ * `MESSAGE_BREAKPOINT_STRIDE` back when the tail is long enough to outrun the
414
+ * 20-block lookback window. Mutates in place — the messages were already cloned
415
+ * by {@link mergeAdjacent}.
416
+ */
417
+ function placeMessageBreakpoints(messages, ttl) {
418
+ const blocks = [];
419
+ for (const message of messages) {
420
+ for (const block of message.content) {
421
+ blocks.push(block);
422
+ }
423
+ }
424
+ if (blocks.length === 0)
425
+ return;
426
+ const targets = new Set([blocks.length - 1]);
427
+ if (blocks.length > MESSAGE_BREAKPOINT_STRIDE + 1) {
428
+ targets.add(blocks.length - 1 - MESSAGE_BREAKPOINT_STRIDE);
429
+ }
430
+ for (const index of targets) {
431
+ const block = blocks[index];
432
+ // `thinking` and `redacted_thinking` reject cache_control; skip them rather
433
+ // than 400 the request, and let the neighbouring breakpoint carry the round.
434
+ if (!block ||
435
+ block.type === "thinking" ||
436
+ block.type === "redacted_thinking")
437
+ continue;
438
+ block.cache_control = cacheControl(ttl);
439
+ }
440
+ }
441
+ /** Map the prompt half of a call: system blocks + turns. */
442
+ export function mapPrompt(prompt, options) {
443
+ const warnings = [];
444
+ const ttl = options.cache === false ? undefined : (options.cache ?? "5m");
445
+ const systemText = prompt
446
+ .filter((message) => message.role === "system")
447
+ .map((message) => message.content)
448
+ .filter((text) => text.length > 0);
449
+ const system = systemText.length > 0
450
+ ? systemText.map((text, index) => ({
451
+ type: "text",
452
+ text,
453
+ // Frozen prefix: one breakpoint on the last block covers tools+system.
454
+ ...(ttl && index === systemText.length - 1
455
+ ? { cache_control: cacheControl(ttl) }
456
+ : {})
457
+ }))
458
+ : undefined;
459
+ const mapped = prompt
460
+ .map((message) => mapMessage(message, warnings))
461
+ .filter((message) => message !== undefined);
462
+ const messages = answerOrphanedToolUses(mergeAdjacent(mapped), warnings);
463
+ // Every mapping branch above drops a turn that maps to nothing, which is
464
+ // right per-message and wrong for the request: Anthropic requires the first
465
+ // turn to be `user`, so a leading user message whose only part was empty text
466
+ // leaves the conversation opening on `assistant` and 400s. Cheaper to notice
467
+ // here than in a log.
468
+ if (messages.length > 0 && messages[0].role !== "user") {
469
+ warnings.push({
470
+ type: "other",
471
+ message: "the first mapped turn was not a user turn; a placeholder was inserted " +
472
+ "because the Messages API requires the conversation to open on one"
473
+ });
474
+ messages.unshift({ role: "user", content: [{ type: "text", text: "…" }] });
475
+ }
476
+ if (ttl)
477
+ placeMessageBreakpoints(messages, ttl);
478
+ return { system, messages, warnings };
479
+ }
480
+ /** Map tool definitions, putting the frozen-prefix breakpoint on the last one. */
481
+ export function mapTools(tools, ttl, warnings) {
482
+ const functionTools = tools.filter((tool) => tool.type === "function");
483
+ for (const tool of tools) {
484
+ if (tool.type !== "function") {
485
+ warnings.push({
486
+ type: "unsupported",
487
+ feature: "provider-defined tool",
488
+ details: `tool "${tool.name}" was dropped; only function tools are mapped`
489
+ });
490
+ }
491
+ }
492
+ if (functionTools.length === 0)
493
+ return undefined;
494
+ return functionTools.map((tool, index) => ({
495
+ name: tool.name,
496
+ ...(tool.description ? { description: tool.description } : {}),
497
+ input_schema: tool.inputSchema,
498
+ ...(ttl && index === functionTools.length - 1
499
+ ? { cache_control: cacheControl(ttl) }
500
+ : {})
501
+ }));
502
+ }
503
+ /** Map tool choice. Note V4 uses `{type:"required"}`, not the bare string. */
504
+ export function mapToolChoice(toolChoice) {
505
+ if (!toolChoice)
506
+ return undefined;
507
+ switch (toolChoice.type) {
508
+ case "auto":
509
+ return { type: "auto" };
510
+ case "none":
511
+ return { type: "none" };
512
+ // Core sets this on every round: "you must call one of the control tools".
513
+ case "required":
514
+ return { type: "any" };
515
+ case "tool":
516
+ return { type: "tool", name: toolChoice.toolName };
517
+ }
518
+ }
519
+ /**
520
+ * Sampling parameters Claude Opus 5 and its siblings removed.
521
+ *
522
+ * Sending any of them is a 400, so they are dropped rather than forwarded.
523
+ * `warnings` is exactly the channel the spec provides for saying so — silently
524
+ * ignoring a caller's `temperature` would be the worse failure.
525
+ */
526
+ const REMOVED_SAMPLING_SETTINGS = [
527
+ "temperature",
528
+ "topP",
529
+ "topK",
530
+ "seed",
531
+ "presencePenalty",
532
+ "frequencyPenalty"
533
+ ];
534
+ export function collectSamplingWarnings(options) {
535
+ return REMOVED_SAMPLING_SETTINGS.filter((setting) => options[setting] !== undefined).map((setting) => ({
536
+ type: "unsupported",
537
+ feature: setting,
538
+ details: "Claude 4.7+ models removed sampling parameters; the value was dropped. " +
539
+ "Steer with the prompt, or with output_config.effort."
540
+ }));
541
+ }