Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.6.11",
"version": "0.6.12",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand All @@ -11,6 +11,7 @@
"./client": "./src/client.ts",

"./anthropic": "./src/adapters/anthropic/adapter.ts",
"./anthropic/utils": "./src/adapters/anthropic/utils.ts",
"./google-genai": "./src/adapters/google_genai/adapter.ts",
"./gemini": "./src/adapters/gemini/adapter.ts",
"./vertex-ai": "./src/adapters/vertex_ai/adapter.ts",
Expand Down
35 changes: 15 additions & 20 deletions src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,6 @@ export function anthropicModel<zO, zI, TModel extends AnthropicModels>(options:
* assuming a hit.
*/
cache?: boolean | AnthropicCacheOptions;
/**
* Compile the tool schemas into a decoding grammar so arguments are guaranteed to validate.
*
* Off by default: Anthropic compiles every strict tool on the request into one grammar and rejects
* the whole request with "The compiled grammar is too large" past an undocumented ceiling, which a
* dozen ordinary tools already clear on 4.6-generation models. Only worth enabling for a small,
* fixed toolset.
*
* Structured output compiles into that same grammar on models that support it natively, so an
* agent with a large output schema can reach the ceiling with this off.
*/
strictTools?: boolean;
baseUrl?: string;
apiKey?: string;
client?: Anthropic;
Expand Down Expand Up @@ -157,7 +145,7 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
stream: async function* stream<zO, zI>(
{ history, instructions, tools, signal, output, cache: cacheDefault }: AdapterStreamOptions<zO, zI>,
): AdapterStreamIterator {
const normalizedTools = normalizeAnthropicTools(tools, options.strictTools);
const normalizedTools = normalizeAnthropicTools(tools);
const anthropicHistory = await getAnthropicHistory({ history, normalizedTools, signal });

// Tools mean an agent loop, which rereads its prefix every turn and profits from caching.
Expand Down Expand Up @@ -277,13 +265,20 @@ ${JSON.stringify(structuredOutput.originalJsonSchema, null, 2)}
reasoningSignatures.delete(part.index);
} else if (endingPart.type === "tool_use") {
const tool = normalizedTools.find((tool) => tool.anthropic.name === endingPart.kind);
const restoredContent = endingPart.content
? JSON.stringify(
tool?.compatibility
? tool.compatibility.fromProvider(JSON.parse(endingPart.content))
: JSON.parse(endingPart.content),
)
: undefined;
let restoredContent: string | undefined;
try {
restoredContent = endingPart.content
? JSON.stringify(
tool?.compatibility
? tool.compatibility.fromProvider(JSON.parse(endingPart.content))
: JSON.parse(endingPart.content),
)
: undefined;
} catch {
// Throwing would end the whole run over one tool call.
// The agent rejects unparseable arguments and reports that back for the model to retry from.
restoredContent = endingPart.content;
}
yield {
type: "tool_use",
index: part.index,
Expand Down
12 changes: 10 additions & 2 deletions src/adapters/anthropic/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,22 @@ export async function getAnthropicHistory(options: {
}
case "tool_use": {
const tool = options.normalizedTools.find((tool) => tool.original.name === historyItem.kind);
const content = historyItem.content ? JSON.parse(historyItem.content) : {};
let input: unknown;
try {
const content = historyItem.content ? JSON.parse(historyItem.content) : {};
input = tool?.compatibility ? tool.compatibility.toProvider(content) : content;
} catch {
// A turn whose arguments never parsed still has to replay as a legal tool_use block.
// Throwing here would make the whole conversation unsendable to any model.
input = historyItem.content;
}
anthropicHistory.push({
role: "assistant",
content: [{
type: "tool_use",
id: historyItem.tool_use_id,
name: tool?.anthropic.name ?? normalizeToolName(historyItem.kind),
input: ensureToolInputObject(tool?.compatibility ? tool.compatibility.toProvider(content) : content),
input: ensureToolInputObject(input),
}],
});
break;
Expand Down
11 changes: 7 additions & 4 deletions src/adapters/anthropic/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ export const anthropicSchemaCompatibilityFeatures: SchemaCompatibilityFeatures =
},
};

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

Expand All @@ -61,7 +66,6 @@ export function normalizeAnthropicTools(tools: AnyTool[], strict = false): Anthr
anthropic: {
name,
strict: false,
eager_input_streaming: true,
input_schema: { type: "object" },
description: tool.description,
},
Expand All @@ -79,8 +83,7 @@ export function normalizeAnthropicTools(tools: AnyTool[], strict = false): Anthr
original: tool,
anthropic: {
name,
strict,
eager_input_streaming: true,
strict: tool.anthropicStrict ?? true,
input_schema: compatibleSchema.jsonSchema,
description: compatibleSchema.instructions
? `${tool.description}\n\n${compatibleSchema.instructions}`
Expand Down
23 changes: 23 additions & 0 deletions src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ interface ToolOptions<zO, zI, TModelOutput> {
* @experimental Might be removed or have its behaviour modified without any notice
*/
timeout?: number;
/**
* Whether Anthropic compiles this tool's schema into the request's decoding grammar, which
* guarantees its arguments validate.
* Every other provider ignores it.
*
* Anthropic budgets that grammar across the whole request and rejects the request once a toolset
* exceeds it, so one wide schema can cost every other tool its guarantee.
*
* Compiling is slow the first time: a 15-tool request took 96s cold against 2.5s once Anthropic
* had the grammar cached, and the cache is dropped when any schema on the request changes.
*
* @experimental Might be removed or have its behaviour modified without any notice
* @default true
*/
anthropicStrict?: boolean;
}

/**
Expand Down Expand Up @@ -85,6 +100,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
#retries: number;
#signal?: AbortSignal;
#timeout?: number;
#anthropicStrict?: boolean;

constructor({
name,
Expand All @@ -94,6 +110,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
retries,
signal,
timeout,
anthropicStrict,
}: ToolOptions<zO, zI, TModelOutput>) {
this.#name = name;
this.#normalizedName = normalizeToolName(name);
Expand All @@ -103,6 +120,7 @@ export class Tool<zO = unknown, zI = unknown, TModelOutput = unknown> {
this.#retries = retries ?? 0;
this.#signal = signal;
this.#timeout = timeout;
this.#anthropicStrict = anthropicStrict;
}

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

/** @experimental Might be removed or have its behaviour modified without any notice */
get anthropicStrict(): boolean | undefined {
return this.#anthropicStrict;
}

async execute(
input: ExecuteFuncInput<zO>,
context: ExecuteContext,
Expand Down
100 changes: 97 additions & 3 deletions tests/adapters/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,16 +446,110 @@ Deno.test("string formats are described in instructions instead of the schema",
assert(compatibility.instructions.includes("`input.slug` must match `^[a-z]+$`"));
});

Deno.test("tools are not strict unless the caller opts in", () => {
Deno.test("tools are strict unless one opts out", () => {
const options = {
description: "A tool",
parameters: z.object({ query: z.string() }),
execute: () => "ok",
};
const plain = new Tool({ ...options, name: "plain" });
const optOut = new Tool({ ...options, name: "opt_out", anthropicStrict: false });

assertEquals(normalizeAnthropicTools([plain, optOut]).map(({ anthropic }) => anthropic.strict), [true, false]);
});

Deno.test("tool input is not streamed eagerly, so the API validates it", () => {
const tool = new Tool({
name: "search",
description: "A tool",
parameters: z.object({ query: z.string() }),
execute: () => "ok",
});

const voidTool = new Tool({
name: "ping",
description: "A tool",
parameters: z.void(),
execute: () => "ok",
});

for (const normalized of normalizeAnthropicTools([tool, voidTool])) {
assertEquals("eager_input_streaming" in normalized.anthropic, false);
}
});

Deno.test("a malformed tool call still replays as a legal tool_use block", async () => {
const tool = new Tool({
name: "search",
description: "A tool",
parameters: z.object({ query: z.string() }),
execute: () => "ok",
});
const malformed = '{"query": what is a cat}';

assertEquals(normalizeAnthropicTools([tool])[0].anthropic.strict, false);
assertEquals(normalizeAnthropicTools([tool], true)[0].anthropic.strict, true);
const history = await getAnthropicHistory({
history: [
{ type: "tool_use", tool_use_id: "call_1", kind: "search", content: malformed },
{ type: "tool_result_text", tool_use_id: "call_1", content: "Error: Invalid parameters for tool" },
],
normalizedTools: normalizeAnthropicTools([tool]),
signal: AbortSignal.abort(),
});

assertEquals(history[0], {
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "search", input: { content: malformed } }],
});
});

Deno.test("malformed tool arguments are handed on rather than ending the run", async () => {
const tool = new Tool({
name: "search",
description: "A tool",
parameters: z.object({ query: z.string() }),
execute: () => "ok",
});
const malformed = '{"query": what is a cat}';
const events = [
{ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "search" } },
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: malformed } },
{ type: "content_block_stop", index: 0 },
];
const adapter = anthropicModel({
model: "claude-opus-5",
apiKey: "unused",
client: {
beta: {
messages: {
stream: () =>
Object.assign(
async function* () {
yield* events;
}(),
{
finalMessage: () => Promise.resolve({ usage: { input_tokens: 1, output_tokens: 1 } }),
},
),
},
},
// deno-lint-ignore no-explicit-any
} as any,
});

const { items } = await collectAdapterStream(adapter.stream({
history: [{ type: "input_text", content: "hi" }],
instructions: "",
tools: [tool],
signal: AbortSignal.timeout(1000),
}));

assertEquals(items.filter((item) => item.type === "tool_use"), [{
type: "tool_use",
index: 0,
kind: tool.name,
tool_use_id: "call_1",
content: malformed,
}]);
});

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