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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alphaxiv/agents",
"version": "0.6.9",
"version": "0.6.10",
"license": "MIT",
"fmt": {
"lineWidth": 120
Expand Down
14 changes: 13 additions & 1 deletion src/adapters/anthropic/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ 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 @@ -145,7 +157,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);
const normalizedTools = normalizeAnthropicTools(tools, options.strictTools);
const anthropicHistory = await getAnthropicHistory({ history, normalizedTools, signal });

// Tools mean an agent loop, which rereads its prefix every turn and profits from caching.
Expand Down
5 changes: 3 additions & 2 deletions src/adapters/anthropic/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const anthropicSchemaCompatibilityFeatures: SchemaCompatibilityFeatures =
},
strings: {
length: "instructions",
format: "instructions",
},
numbers: {
integerType: "number",
Expand All @@ -50,7 +51,7 @@ export const anthropicSchemaCompatibilityFeatures: SchemaCompatibilityFeatures =
},
};

export function normalizeAnthropicTools(tools: AnyTool[]): AnthropicToolMap[] {
export function normalizeAnthropicTools(tools: AnyTool[], strict = false): AnthropicToolMap[] {
return tools.map((tool): AnthropicToolMap => {
const name = tool.normalizedName;

Expand Down Expand Up @@ -78,7 +79,7 @@ export function normalizeAnthropicTools(tools: AnyTool[]): AnthropicToolMap[] {
original: tool,
anthropic: {
name,
strict: true,
strict,
eager_input_streaming: true,
input_schema: compatibleSchema.jsonSchema,
description: compatibleSchema.instructions
Expand Down
1 change: 1 addition & 0 deletions src/adapters/shared/openai_compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const openAISchemaCompatibilityFeatures: SchemaCompatibilityFeatures = {
},
strings: {
length: "instructions",
format: "native",
},
numbers: {
integerType: "number",
Expand Down
28 changes: 23 additions & 5 deletions src/adapters/shared/schema_compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export interface SchemaCompatibilityFeatures {
};
strings: {
length: "native" | "instructions";
/**
* Named formats (`uri`, `date`, `uuid`, ...) and regex patterns. Providers that compile schemas
* into a decoding grammar pay for them, and Zod's date format alone spells out every leap year.
*/
format: "native" | "instructions";
};
numbers: {
integerType: "native" | "number";
Expand Down Expand Up @@ -313,16 +318,24 @@ function transformStringSchema(schema: ZodJsonSchema, path: string, context: Com
}
}

const transformed = context.features.strings.length === "instructions"
? omitSchemaKeywords(schema, ["minLength", "maxLength"])
: { ...schema };
const omitted = context.features.strings.length === "instructions" ? ["minLength", "maxLength"] : [];
const describesFormat = context.features.strings.format === "instructions";
const format = describesFormat && typeof schema.format === "string" ? schema.format : undefined;
const pattern = describesFormat && typeof schema.pattern === "string" ? schema.pattern : undefined;
if (format || pattern) omitted.push("format", "pattern");

if (constraints.length > 0) {
context.constraints.push(`- \`${path}\` must have ${constraints.join(" and ")}`);
}
// A named format already says what its pattern spells out, so the regex itself stays out of the prompt.
if (format) {
context.constraints.push(`- \`${path}\` must be a valid ${format}`);
} else if (pattern) {
context.constraints.push(`- \`${path}\` must match \`${pattern}\``);
}

return {
jsonSchema: transformed,
jsonSchema: omitSchemaKeywords(schema, omitted),
toProvider: identity,
fromProvider: identity,
};
Expand Down Expand Up @@ -587,6 +600,9 @@ function stripUnsupportedKeywords(schema: ZodJsonSchema, context: CompatibilityC
case "minLength":
case "maxLength":
return context.features.strings.length === "instructions" ? [] : [[key, value]];
case "format":
case "pattern":
return context.features.strings.format === "instructions" ? [] : [[key, value]];
case "maxItems":
return context.features.arrays.length === "native" ? [[key, value]] : [];
case "minItems":
Expand Down Expand Up @@ -831,8 +847,10 @@ function schemaTypeToString(schema: ZodJsonSchemaInput | ZodJsonSchemaInput[] |
return `${schemaTypeToString(schema.items)}[]`;
case "integer":
return "integer";
case "number":
// Record keys never reach transformStringSchema, so this string is the only place their format can land.
case "string":
return typeof schema.format === "string" ? schema.format : type;
case "number":
case "boolean":
case "null":
return type;
Expand Down
32 changes: 32 additions & 0 deletions tests/adapters/anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,38 @@ Deno.test("tool schemas can be wrapped to satisfy Anthropic top-level object req
assert(compatibility.instructions.includes("at least 2 characters"));
});

Deno.test("string formats are described in instructions instead of the schema", () => {
const compatibility = createAnthropicCompatibleSchema(
z.object({ url: z.url(), day: z.iso.date(), slug: z.string().regex(/^[a-z]+$/) }),
{
kind: "tool",
requireTopLevelObject: true,
rootPath: "input",
},
);

assertEquals(compatibility.jsonSchema.properties, {
url: { type: "string" },
day: { type: "string" },
slug: { type: "string" },
});
assert(compatibility.instructions.includes("`input.url` must be a valid uri"));
assert(compatibility.instructions.includes("`input.day` must be a valid date"));
assert(compatibility.instructions.includes("`input.slug` must match `^[a-z]+$`"));
});

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

assertEquals(normalizeAnthropicTools([tool])[0].anthropic.strict, false);
assertEquals(normalizeAnthropicTools([tool], true)[0].anthropic.strict, true);
});

Deno.test("Anthropic retry feedback is replayed as a user message", async () => {
const history = await getAnthropicHistory({
history: [
Expand Down
14 changes: 14 additions & 0 deletions tests/adapters/openai-completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,20 @@ Deno.test("OpenAI Completions uses reversible OpenAI compatibility for tuple and
assert(normalizedTool?.openAI.function.description?.includes("<input_requirements>"));
});

Deno.test("OpenAI Completions keeps string formats in the tool schema", () => {
const [normalizedTool] = normalizeOpenAICompletionsTools([
new Tool({
name: "Fetch Page",
description: "Reads a url",
parameters: z.object({ url: z.url() }).strict(),
execute: () => "unused",
}),
]);

const parameters = normalizedTool?.openAI.function.parameters as { properties?: Record<string, unknown> };
assertEquals(parameters.properties?.url, { type: "string", format: "uri" });
});

Deno.test("OpenAI Completions restores structured output from OpenAI-compatible surrogate shapes", async () => {
let capturedRequest: unknown;

Expand Down
Loading