@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.
- package/README.md +6 -4
- package/dist/a2a/notify.d.ts +4 -3
- package/dist/a2a/notify.js +4 -3
- package/dist/agent/anthropic/index.d.ts +15 -0
- package/dist/agent/anthropic/index.js +19 -0
- package/dist/agent/anthropic/language-model.d.ts +59 -0
- package/dist/agent/anthropic/language-model.js +442 -0
- package/dist/agent/anthropic/prompt.d.ts +84 -0
- package/dist/agent/anthropic/prompt.js +541 -0
- package/dist/agent/anthropic/runtime.d.ts +79 -0
- package/dist/agent/anthropic/runtime.js +130 -0
- package/dist/agent/control.js +10 -9
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +14 -13
- package/dist/agent/final-reply.js +28 -11
- package/dist/agent/history.d.ts +3 -3
- package/dist/agent/history.js +2 -2
- package/dist/agent/index.d.ts +4 -2
- package/dist/agent/index.js +4 -2
- package/dist/agent/inference.d.ts +58 -1
- package/dist/agent/inference.js +44 -0
- package/dist/agent/model.d.ts +42 -25
- package/dist/agent/model.js +1 -48
- package/dist/agent/session.d.ts +6 -7
- package/dist/agent/session.js +3 -3
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/config.d.ts +49 -15
- package/dist/config.js +30 -1
- package/dist/contract/plugin.d.ts +63 -3
- package/dist/contract/plugin.js +76 -0
- package/dist/contract/recipe.d.ts +16 -17
- package/dist/db/db.d.ts +0 -1
- package/dist/db/migrations/index.js +8 -1
- package/dist/db/models/subtasks.d.ts +24 -25
- package/dist/db/models/subtasks.js +33 -76
- package/dist/db/schema.d.ts +2 -21
- package/dist/db/schema.js +2 -4
- package/dist/host/agent.d.ts +58 -4
- package/dist/host/agent.js +63 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/platform.d.ts +74 -11
- package/dist/platform.js +76 -13
- package/dist/round/agent.d.ts +36 -31
- package/dist/round/agent.js +61 -89
- package/dist/round/index.d.ts +3 -2
- package/dist/round/index.js +2 -2
- package/dist/round/policy.d.ts +2 -2
- package/dist/round/subagent.d.ts +19 -1
- package/dist/round/subagent.js +22 -5
- package/dist/round/turn.d.ts +32 -13
- package/dist/round/turn.js +83 -16
- package/dist/round/workflow.d.ts +23 -7
- package/dist/round/workflow.js +132 -65
- package/dist/runtime/index.d.ts +4 -2
- package/dist/runtime/index.js +6 -0
- package/dist/subagent/fingerprint.d.ts +2 -2
- package/dist/subagent/fingerprint.js +8 -17
- package/dist/subagent/index.d.ts +6 -4
- package/dist/subagent/index.js +8 -6
- package/dist/subagent/prompt.d.ts +4 -5
- package/dist/subagent/prompt.js +0 -8
- package/dist/subagent/run.d.ts +8 -1
- package/dist/subagent/run.js +59 -9
- package/dist/subtasks/catalog.d.ts +1 -1
- package/dist/subtasks/catalog.js +1 -1
- package/dist/subtasks/decomposition.d.ts +16 -20
- package/dist/subtasks/decomposition.js +27 -75
- package/dist/subtasks/delegate.d.ts +20 -1
- package/dist/subtasks/delegate.js +21 -16
- package/dist/subtasks/index.d.ts +1 -2
- package/dist/subtasks/index.js +1 -2
- package/dist/subtasks/subtask-types.d.ts +0 -8
- package/dist/subtasks/subtask-types.js +0 -7
- package/dist/subtasks/types.d.ts +45 -70
- package/dist/testing/mock-model.d.ts +35 -0
- package/dist/testing/mock-model.js +75 -0
- package/dist/testing/vcr-global-setup.d.ts +1 -3
- package/dist/testing/vcr-global-setup.js +1 -3
- package/dist/worker/index.d.ts +5 -12
- package/dist/worker/index.js +5 -12
- package/package.json +19 -1
- package/dist/subtasks/scheduler.d.ts +0 -48
- 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
|
+
}
|