diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index ee1c32629e..542d851e0d 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -815,7 +815,7 @@ Input delivered to a hosted agent-service detached execution callback. | `getProjectAgentRuntimeAgentIdCandidates` | Return project agent runtime agent ID candidates. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/agent-runtime.ts#L223) | | `getProjectSteeringMutation` | Return project steering mutation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/project/steering-mutation.ts#L112) | | `getProviderNativeToolNames` | Return provider native tool names. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-native-tool-inventory.ts#L48) | -| `getProviderToolProfile` | Return provider tool profile. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L51) | +| `getProviderToolProfile` | Return provider tool profile. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L59) | | `getRuntimeAgentMarkdownDefinition` | Definition for get runtime agent markdown. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-markdown-adapter.ts#L45) | | `getRuntimeProjectFile` | Return runtime project file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/project-files-client.ts#L404) | | `getRuntimeProjectFiles` | Return runtime project files. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/project-files-client.ts#L671) | @@ -981,11 +981,11 @@ Input delivered to a hosted agent-service detached execution callback. | `runWithRunEventSink` | Scope an operation to a run event sink. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/runtime/run-event-sink-context.ts#L24) | | `sanitizeDefaultHostedChildRequestedTools` | Sanitize default hosted child requested tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L276) | | `sanitizeHostedChildRequestedTools` | Sanitize hosted child requested tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L57) | -| `sanitizeProviderToolSchema` | Zod schema for sanitize provider tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L409) | +| `sanitizeProviderToolSchema` | Zod schema for sanitize provider tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L549) | | `selectDefaultHostedChildForkRuntimeTools` | Select default hosted child fork runtime tools helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L291) | | `selectHostedChildForkRuntimeTools` | Select hosted child fork runtime tools helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L208) | -| `selectProviderCompatibleToolNames` | Select provider compatible tool names helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L90) | -| `selectProviderCompatibleTools` | Select provider compatible tools helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L118) | +| `selectProviderCompatibleToolNames` | Select provider compatible tool names helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L98) | +| `selectProviderCompatibleTools` | Select provider compatible tools helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/provider-tool-compat.ts#L126) | | `shouldBlockHostedChildSameTurnRetry` | Should block hosted child same turn retry helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-status.ts#L49) | | `shouldContinueForkRuntimeStep` | Should continue fork runtime step helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-step-progress.ts#L42) | | `shouldFailEmptyHostedFinalizedMessage` | Message shape for should fail empty hosted finalized. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/stream-terminal-error.ts#L132) | diff --git a/src/agent/runtime/model-tool-converter.test.ts b/src/agent/runtime/model-tool-converter.test.ts index 852539712c..336b80fd0c 100644 --- a/src/agent/runtime/model-tool-converter.test.ts +++ b/src/agent/runtime/model-tool-converter.test.ts @@ -180,6 +180,54 @@ describe("model-tool-converter", () => { }); }); + it("removes top-level composition from a 198-tool Anthropic child set", () => { + const tools: ToolDefinition[] = [ + ...Array.from({ length: 197 }, (_, index) => ({ + name: `child_tool_${index}`, + description: `Child tool ${index}`, + parameters: { type: "object" as const, properties: {} }, + })), + { + name: "load_skill", + description: "Load a skill body or one of its advertised references", + parameters: { + anyOf: [ + { + type: "object", + properties: { + skillId: { type: "string", enum: ["unloaded"] }, + file: { type: "string" }, + }, + required: ["skillId"], + }, + { + type: "object", + properties: { + skillId: { type: "string", enum: ["loaded"] }, + file: { type: "string" }, + }, + required: ["skillId", "file"], + }, + ], + } as never, + }, + ]; + + const result = convertToolsToRuntimeTools(tools, { + model: "veryfront-cloud/anthropic/claude-opus-4-6", + }); + const schema = getRuntimeToolSchema(result?.load_skill) as Record; + const properties = schema.properties as Record>; + + assertEquals(Object.keys(result ?? {}).length, 198); + assertEquals(schema.type, "object"); + assertEquals(Object.hasOwn(schema, "anyOf"), false); + assertEquals(Object.hasOwn(schema, "oneOf"), false); + assertEquals(Object.hasOwn(schema, "allOf"), false); + assertEquals(Object.keys(properties), ["skillId", "file"]); + assertEquals(schema.required, ["skillId"]); + }); + it("adds provider-native web_search for anthropic models when explicitly configured", () => { const result = convertToolsToRuntimeTools([], { model: "anthropic/claude-sonnet-4-6", diff --git a/src/agent/runtime/provider-tool-compat.test.ts b/src/agent/runtime/provider-tool-compat.test.ts index 094f47d58a..343fb68594 100644 --- a/src/agent/runtime/provider-tool-compat.test.ts +++ b/src/agent/runtime/provider-tool-compat.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { ToolDefinition } from "#veryfront/tool"; import { getProviderToolProfile, + normalizeProviderToolInputSchema, sanitizeProviderToolSchema, selectProviderCompatibleToolNames, selectProviderCompatibleTools, @@ -26,6 +27,27 @@ function containsKey(value: unknown, key: string): boolean { } describe("provider-tool-compat", () => { + it("returns independent permissive fallback schemas", () => { + for ( + const createFallback of [ + () => normalizeProviderToolInputSchema(null as never), + () => + sanitizeProviderToolSchema(null as never, { + model: "anthropic/claude-opus-4-6", + }), + ] + ) { + const first = createFallback(); + (first.properties as Record).injected = { type: "string" }; + + assertEquals(createFallback(), { + type: "object", + properties: {}, + additionalProperties: true, + }); + } + }); + it("caps OpenAI-compatible tool names while preserving required tools first", () => { const requiredToolNames = ["form_input", "invoke_agent", "load_skill", "sleep"]; const remoteToolNames = Array.from({ length: 150 }, (_, index) => `remote_${index}`); @@ -328,4 +350,66 @@ describe("provider-tool-compat", () => { ["fine"], ); }); + + it("removes every unsupported Anthropic root composition keyword", () => { + for (const keyword of ["allOf", "anyOf", "oneOf"] as const) { + const sanitized = sanitizeProviderToolSchema( + { + [keyword]: [ + { + type: "object", + properties: { shared: { type: "string" } }, + required: ["shared"], + }, + { + type: "object", + properties: { extra: { type: "number" } }, + required: ["extra"], + }, + ], + } as never, + { model: "anthropic/claude-opus-4-6" }, + ); + + assertEquals(sanitized.type, "object"); + assertEquals(Object.hasOwn(sanitized, "allOf"), false); + assertEquals(Object.hasOwn(sanitized, "anyOf"), false); + assertEquals(Object.hasOwn(sanitized, "oneOf"), false); + assertEquals(Object.keys(sanitized.properties ?? {}), ["shared", "extra"]); + assertEquals(sanitized.required, keyword === "allOf" ? ["shared", "extra"] : undefined); + } + }); + + it("preserves local reference constraints when flattening Anthropic compositions", () => { + const sanitized = sanitizeProviderToolSchema( + { + anyOf: [ + { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + { $ref: "#/$defs/defaultQuery" }, + ], + $defs: { + defaultQuery: { + type: "object", + properties: { fallback: { type: "boolean" } }, + required: ["fallback"], + }, + }, + } as never, + { model: "anthropic/claude-opus-4-6" }, + ); + + assertEquals(Object.keys(sanitized.properties ?? {}), ["query", "fallback"]); + assertEquals(sanitized.required, undefined); + assertEquals(sanitized.$defs, { + defaultQuery: { + type: "object", + properties: { fallback: { type: "boolean" } }, + required: ["fallback"], + }, + }); + }); }); diff --git a/src/agent/runtime/provider-tool-compat.ts b/src/agent/runtime/provider-tool-compat.ts index 4f3b9d9117..4c4ba895db 100644 --- a/src/agent/runtime/provider-tool-compat.ts +++ b/src/agent/runtime/provider-tool-compat.ts @@ -28,6 +28,14 @@ const PERMISSIVE_TOOL_INPUT_SCHEMA: JsonSchema = { properties: {}, additionalProperties: true, }; + +function createPermissiveToolInputSchema(): JsonSchema { + return { + ...PERMISSIVE_TOOL_INPUT_SCHEMA, + properties: {}, + }; +} + const PROVIDER_TOOL_PROPERTY_KEY_PATTERN = /^[a-zA-Z0-9_.-]{1,64}$/; const GOOGLE_UNSUPPORTED_SCHEMA_KEYS = new Set([ @@ -385,6 +393,138 @@ function sanitizeMoonshotSchemaValue( return sanitized; } +type AnthropicCompositionKeyword = "allOf" | "anyOf"; + +function getSchemaRequiredNames(schema: Record): string[] { + return Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : []; +} + +function getMergedRequiredNames( + schemas: readonly Record[], + keyword: AnthropicCompositionKeyword, +): string[] { + if (schemas.length === 0) return []; + const requiredBySchema = schemas.map(getSchemaRequiredNames); + if (keyword === "allOf") { + return [...new Set(requiredBySchema.flat())]; + } + + return requiredBySchema[0]?.filter((name) => + requiredBySchema.slice(1).every((required) => required.includes(name)) + ) ?? []; +} + +function getMergedPropertySchema( + schemas: readonly unknown[], + keyword: AnthropicCompositionKeyword, +): unknown { + if (schemas.length === 1) return schemas[0]; + const [first, ...rest] = schemas; + const serializedFirst = JSON.stringify(first); + if (rest.every((schema) => JSON.stringify(schema) === serializedFirst)) { + return first; + } + return { [keyword]: schemas }; +} + +function mergeAnthropicObjectSchemas( + schemas: readonly Record[], + keyword: AnthropicCompositionKeyword, +): Record { + const mergedProperties = new Map(); + for (const schema of schemas) { + if (!isPlainRecord(schema.properties)) continue; + for (const [name, propertySchema] of Object.entries(schema.properties)) { + const variants = mergedProperties.get(name) ?? []; + variants.push(propertySchema); + mergedProperties.set(name, variants); + } + } + + const properties = Object.fromEntries( + [...mergedProperties].map(([name, variants]) => [ + name, + getMergedPropertySchema(variants, keyword), + ]), + ); + const required = getMergedRequiredNames(schemas, keyword); + const additionalProperties = schemas.length > 0 && + schemas.every((schema) => schema.additionalProperties === false) + ? false + : undefined; + const defs = Object.assign( + {}, + ...schemas.flatMap((schema) => isPlainRecord(schema.$defs) ? [schema.$defs] : []), + ); + const definitions = Object.assign( + {}, + ...schemas.flatMap((schema) => isPlainRecord(schema.definitions) ? [schema.definitions] : []), + ); + + return { + type: "object", + ...(Object.keys(properties).length > 0 ? { properties } : {}), + ...(required.length > 0 ? { required } : {}), + ...(additionalProperties === false ? { additionalProperties } : {}), + ...(Object.keys(defs).length > 0 ? { $defs: defs } : {}), + ...(Object.keys(definitions).length > 0 ? { definitions } : {}), + }; +} + +function sanitizeAnthropicSchemaRoot( + schema: unknown, + rootSchema: unknown = schema, + seenRefs: ReadonlySet = new Set(), +): Record { + if (!isPlainRecord(schema)) { + return createPermissiveToolInputSchema(); + } + + if (typeof schema.$ref === "string" && !seenRefs.has(schema.$ref)) { + const resolved = resolveLocalJsonPointer(rootSchema, schema.$ref); + if (isPlainRecord(resolved) && resolved !== schema) { + const nextSeenRefs = new Set(seenRefs); + nextSeenRefs.add(schema.$ref); + const { $ref: _ref, ...siblings } = schema; + return sanitizeAnthropicSchemaRoot( + { ...resolved, ...siblings }, + rootSchema, + nextSeenRefs, + ); + } + } + + const { + allOf: rawAllOf, + anyOf: rawAnyOf, + oneOf: rawOneOf, + ...root + } = schema; + let sanitized: Record = { ...root, type: "object" }; + + for ( + const [rawBranches, keyword] of [ + [rawAllOf, "allOf"], + [rawAnyOf, "anyOf"], + [rawOneOf, "anyOf"], + ] as const + ) { + if (!Array.isArray(rawBranches) || rawBranches.length === 0) continue; + const branches = rawBranches.map((branch) => + sanitizeAnthropicSchemaRoot(branch, rootSchema, seenRefs) + ); + const composed = mergeAnthropicObjectSchemas(branches, keyword); + sanitized = { + ...sanitized, + ...mergeAnthropicObjectSchemas([sanitized, composed], "allOf"), + }; + } + + return sanitized; +} + /** * Normalize a provider tool input schema so every function tool has a * provider-safe JSON Schema object at the root. Remote/MCP tools can omit the @@ -392,7 +532,7 @@ function sanitizeMoonshotSchemaValue( */ export function normalizeProviderToolInputSchema(schema: JsonSchema): JsonSchema { if (!isPlainRecord(schema) || Object.keys(schema).length === 0) { - return { ...PERMISSIVE_TOOL_INPUT_SCHEMA }; + return createPermissiveToolInputSchema(); } if (Object.hasOwn(schema, "type")) { @@ -423,5 +563,9 @@ export function sanitizeProviderToolSchema( return sanitizeMoonshotSchemaValue(propertyKeySafeSchema) as JsonSchema; } + if (profile.provider === "anthropic") { + return sanitizeAnthropicSchemaRoot(propertyKeySafeSchema) as JsonSchema; + } + return propertyKeySafeSchema as JsonSchema; }