Skip to content

Commit 5f75883

Browse files
committed
fix: per-tool strict opt-out, buffered tool input
1 parent 8edd888 commit 5f75883

6 files changed

Lines changed: 154 additions & 30 deletions

File tree

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@alphaxiv/agents",
3-
"version": "0.6.11",
3+
"version": "0.6.12",
44
"license": "MIT",
55
"fmt": {
66
"lineWidth": 120
@@ -11,6 +11,7 @@
1111
"./client": "./src/client.ts",
1212

1313
"./anthropic": "./src/adapters/anthropic/adapter.ts",
14+
"./anthropic/utils": "./src/adapters/anthropic/utils.ts",
1415
"./google-genai": "./src/adapters/google_genai/adapter.ts",
1516
"./gemini": "./src/adapters/gemini/adapter.ts",
1617
"./vertex-ai": "./src/adapters/vertex_ai/adapter.ts",

src/adapters/anthropic/adapter.ts

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,6 @@ export function anthropicModel<zO, zI, TModel extends AnthropicModels>(options:
8181
* assuming a hit.
8282
*/
8383
cache?: boolean | AnthropicCacheOptions;
84-
/**
85-
* Compile the tool schemas into a decoding grammar so arguments are guaranteed to validate.
86-
*
87-
* Off by default: Anthropic compiles every strict tool on the request into one grammar and rejects
88-
* the whole request with "The compiled grammar is too large" past an undocumented ceiling, which a
89-
* dozen ordinary tools already clear on 4.6-generation models. Only worth enabling for a small,
90-
* fixed toolset.
91-
*
92-
* Structured output compiles into that same grammar on models that support it natively, so an
93-
* agent with a large output schema can reach the ceiling with this off.
94-
*/
95-
strictTools?: boolean;
9684
baseUrl?: string;
9785
apiKey?: string;
9886
client?: Anthropic;
@@ -157,7 +145,7 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
157145
stream: async function* stream<zO, zI>(
158146
{ history, instructions, tools, signal, output, cache: cacheDefault }: AdapterStreamOptions<zO, zI>,
159147
): AdapterStreamIterator {
160-
const normalizedTools = normalizeAnthropicTools(tools, options.strictTools);
148+
const normalizedTools = normalizeAnthropicTools(tools);
161149
const anthropicHistory = await getAnthropicHistory({ history, normalizedTools, signal });
162150

163151
// Tools mean an agent loop, which rereads its prefix every turn and profits from caching.
@@ -277,13 +265,20 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
277265
reasoningSignatures.delete(part.index);
278266
} else if (endingPart.type === "tool_use") {
279267
const tool = normalizedTools.find((tool) => tool.anthropic.name === endingPart.kind);
280-
const restoredContent = endingPart.content
281-
? JSON.stringify(
282-
tool?.compatibility
283-
? tool.compatibility.fromProvider(JSON.parse(endingPart.content))
284-
: JSON.parse(endingPart.content),
285-
)
286-
: undefined;
268+
let restoredContent: string | undefined;
269+
try {
270+
restoredContent = endingPart.content
271+
? JSON.stringify(
272+
tool?.compatibility
273+
? tool.compatibility.fromProvider(JSON.parse(endingPart.content))
274+
: JSON.parse(endingPart.content),
275+
)
276+
: undefined;
277+
} catch {
278+
// Throwing would end the whole run over one tool call.
279+
// The agent rejects unparseable arguments and reports that back for the model to retry from.
280+
restoredContent = endingPart.content;
281+
}
287282
yield {
288283
type: "tool_use",
289284
index: part.index,

src/adapters/anthropic/history.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,22 @@ export async function getAnthropicHistory(options: {
7979
}
8080
case "tool_use": {
8181
const tool = options.normalizedTools.find((tool) => tool.original.name === historyItem.kind);
82-
const content = historyItem.content ? JSON.parse(historyItem.content) : {};
82+
let input: unknown;
83+
try {
84+
const content = historyItem.content ? JSON.parse(historyItem.content) : {};
85+
input = tool?.compatibility ? tool.compatibility.toProvider(content) : content;
86+
} catch {
87+
// A turn whose arguments never parsed still has to replay as a legal tool_use block.
88+
// Throwing here would make the whole conversation unsendable to any model.
89+
input = historyItem.content;
90+
}
8391
anthropicHistory.push({
8492
role: "assistant",
8593
content: [{
8694
type: "tool_use",
8795
id: historyItem.tool_use_id,
8896
name: tool?.anthropic.name ?? normalizeToolName(historyItem.kind),
89-
input: ensureToolInputObject(tool?.compatibility ? tool.compatibility.toProvider(content) : content),
97+
input: ensureToolInputObject(input),
9098
}],
9199
});
92100
break;

src/adapters/anthropic/utils.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ export const anthropicSchemaCompatibilityFeatures: SchemaCompatibilityFeatures =
5151
},
5252
};
5353

54-
export function normalizeAnthropicTools(tools: AnyTool[], strict = false): AnthropicToolMap[] {
54+
/**
55+
* `eager_input_streaming` stays off.
56+
* It drops the server-side buffering that validates a tool's arguments, and nothing here reads a
57+
* fragment before the block closes.
58+
*/
59+
export function normalizeAnthropicTools(tools: AnyTool[]): AnthropicToolMap[] {
5560
return tools.map((tool): AnthropicToolMap => {
5661
const name = tool.normalizedName;
5762

@@ -61,7 +66,6 @@ export function normalizeAnthropicTools(tools: AnyTool[], strict = false): Anthr
6166
anthropic: {
6267
name,
6368
strict: false,
64-
eager_input_streaming: true,
6569
input_schema: { type: "object" },
6670
description: tool.description,
6771
},
@@ -79,8 +83,7 @@ export function normalizeAnthropicTools(tools: AnyTool[], strict = false): Anthr
7983
original: tool,
8084
anthropic: {
8185
name,
82-
strict,
83-
eager_input_streaming: true,
86+
strict: tool.anthropicStrict ?? true,
8487
input_schema: compatibleSchema.jsonSchema,
8588
description: compatibleSchema.instructions
8689
? `${tool.description}\n\n${compatibleSchema.instructions}`

src/tool.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@ interface ToolOptions<zO, zI, TModelOutput> {
5252
* @experimental Might be removed or have its behaviour modified without any notice
5353
*/
5454
timeout?: number;
55+
/**
56+
* Whether Anthropic compiles this tool's schema into the request's decoding grammar, which
57+
* guarantees its arguments validate.
58+
* Every other provider ignores it.
59+
*
60+
* Anthropic budgets that grammar across the whole request and rejects the request once a toolset
61+
* exceeds it, so one wide schema can cost every other tool its guarantee.
62+
*
63+
* Compiling is slow the first time: a 15-tool request took 96s cold against 2.5s once Anthropic
64+
* had the grammar cached, and the cache is dropped when any schema on the request changes.
65+
*
66+
* @experimental Might be removed or have its behaviour modified without any notice
67+
* @default true
68+
*/
69+
anthropicStrict?: boolean;
5570
}
5671

5772
/**
@@ -85,6 +100,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
85100
#retries: number;
86101
#signal?: AbortSignal;
87102
#timeout?: number;
103+
#anthropicStrict?: boolean;
88104

89105
constructor({
90106
name,
@@ -94,6 +110,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
94110
retries,
95111
signal,
96112
timeout,
113+
anthropicStrict,
97114
}: ToolOptions<zO, zI, TModelOutput>) {
98115
this.#name = name;
99116
this.#normalizedName = normalizeToolName(name);
@@ -103,6 +120,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
103120
this.#retries = retries ?? 0;
104121
this.#signal = signal;
105122
this.#timeout = timeout;
123+
this.#anthropicStrict = anthropicStrict;
106124
}
107125

108126
get name(): string {
@@ -121,6 +139,11 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
121139
return this.#parameters;
122140
}
123141

142+
/** @experimental Might be removed or have its behaviour modified without any notice */
143+
get anthropicStrict(): boolean | undefined {
144+
return this.#anthropicStrict;
145+
}
146+
124147
async execute(
125148
input: ExecuteFuncInput<zO>,
126149
context: ExecuteContext,

tests/adapters/anthropic.test.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,16 +446,110 @@ Deno.test("string formats are described in instructions instead of the schema",
446446
assert(compatibility.instructions.includes("`input.slug` must match `^[a-z]+$`"));
447447
});
448448

449-
Deno.test("tools are not strict unless the caller opts in", () => {
449+
Deno.test("tools are strict unless one opts out", () => {
450+
const options = {
451+
description: "A tool",
452+
parameters: z.object({ query: z.string() }),
453+
execute: () => "ok",
454+
};
455+
const plain = new Tool({ ...options, name: "plain" });
456+
const optOut = new Tool({ ...options, name: "opt_out", anthropicStrict: false });
457+
458+
assertEquals(normalizeAnthropicTools([plain, optOut]).map(({ anthropic }) => anthropic.strict), [true, false]);
459+
});
460+
461+
Deno.test("tool input is not streamed eagerly, so the API validates it", () => {
462+
const tool = new Tool({
463+
name: "search",
464+
description: "A tool",
465+
parameters: z.object({ query: z.string() }),
466+
execute: () => "ok",
467+
});
468+
469+
const voidTool = new Tool({
470+
name: "ping",
471+
description: "A tool",
472+
parameters: z.void(),
473+
execute: () => "ok",
474+
});
475+
476+
for (const normalized of normalizeAnthropicTools([tool, voidTool])) {
477+
assertEquals("eager_input_streaming" in normalized.anthropic, false);
478+
}
479+
});
480+
481+
Deno.test("a malformed tool call still replays as a legal tool_use block", async () => {
450482
const tool = new Tool({
451483
name: "search",
452484
description: "A tool",
453485
parameters: z.object({ query: z.string() }),
454486
execute: () => "ok",
455487
});
488+
const malformed = '{"query": what is a cat}';
456489

457-
assertEquals(normalizeAnthropicTools([tool])[0].anthropic.strict, false);
458-
assertEquals(normalizeAnthropicTools([tool], true)[0].anthropic.strict, true);
490+
const history = await getAnthropicHistory({
491+
history: [
492+
{ type: "tool_use", tool_use_id: "call_1", kind: "search", content: malformed },
493+
{ type: "tool_result_text", tool_use_id: "call_1", content: "Error: Invalid parameters for tool" },
494+
],
495+
normalizedTools: normalizeAnthropicTools([tool]),
496+
signal: AbortSignal.abort(),
497+
});
498+
499+
assertEquals(history[0], {
500+
role: "assistant",
501+
content: [{ type: "tool_use", id: "call_1", name: "search", input: { content: malformed } }],
502+
});
503+
});
504+
505+
Deno.test("malformed tool arguments are handed on rather than ending the run", async () => {
506+
const tool = new Tool({
507+
name: "search",
508+
description: "A tool",
509+
parameters: z.object({ query: z.string() }),
510+
execute: () => "ok",
511+
});
512+
const malformed = '{"query": what is a cat}';
513+
const events = [
514+
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "search" } },
515+
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: malformed } },
516+
{ type: "content_block_stop", index: 0 },
517+
];
518+
const adapter = anthropicModel({
519+
model: "claude-opus-5",
520+
apiKey: "unused",
521+
client: {
522+
beta: {
523+
messages: {
524+
stream: () =>
525+
Object.assign(
526+
async function* () {
527+
yield* events;
528+
}(),
529+
{
530+
finalMessage: () => Promise.resolve({ usage: { input_tokens: 1, output_tokens: 1 } }),
531+
},
532+
),
533+
},
534+
},
535+
// deno-lint-ignore no-explicit-any
536+
} as any,
537+
});
538+
539+
const { items } = await collectAdapterStream(adapter.stream({
540+
history: [{ type: "input_text", content: "hi" }],
541+
instructions: "",
542+
tools: [tool],
543+
signal: AbortSignal.timeout(1000),
544+
}));
545+
546+
assertEquals(items.filter((item) => item.type === "tool_use"), [{
547+
type: "tool_use",
548+
index: 0,
549+
kind: tool.name,
550+
tool_use_id: "call_1",
551+
content: malformed,
552+
}]);
459553
});
460554

461555
Deno.test("Anthropic retry feedback is replayed as a user message", async () => {

0 commit comments

Comments
 (0)