diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index d2150825bd5e..6fb998b05ec5 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -68,6 +68,8 @@ describe("llama.cpp provider plugin", () => { id: "llama-cpp", label: "Local model (llama.cpp)", createStreamFn: expect.any(Function), + normalizeToolSchemas: expect.any(Function), + inspectToolSchemas: expect.any(Function), auth: [expect.objectContaining({ id: "local" })], }), ); diff --git a/extensions/llama-cpp/index.ts b/extensions/llama-cpp/index.ts index 4fa7fe4b3419..da88241db062 100644 --- a/extensions/llama-cpp/index.ts +++ b/extensions/llama-cpp/index.ts @@ -1,4 +1,5 @@ import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { LLAMA_CPP_PROVIDER_ID, LLAMA_CPP_PROVIDER_LABEL, @@ -53,6 +54,7 @@ export default definePluginEntry({ source: "local llama.cpp runtime", mode: "api-key" as const, }), + ...buildProviderToolCompatFamilyHooks("llamacpp-gbnf"), wizard: { setup: { choiceId: LLAMA_CPP_PROVIDER_ID, diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts index 13d632088f67..4e65f77ee7b9 100644 --- a/extensions/lmstudio/index.test.ts +++ b/extensions/lmstudio/index.test.ts @@ -78,6 +78,13 @@ describe("lmstudio plugin", () => { fetchLmstudioModelsMock.mockReset(); }); + it("registers llama.cpp GBNF tool-schema projection", () => { + expect(registerProvider()).toMatchObject({ + normalizeToolSchemas: expect.any(Function), + inspectToolSchemas: expect.any(Function), + }); + }); + it("preflights the requested LM Studio model before destructive non-interactive reset", async () => { fetchLmstudioModelsMock.mockResolvedValue({ reachable: true, diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts index 99dccef891e0..b180e64217e1 100644 --- a/extensions/lmstudio/index.ts +++ b/extensions/lmstudio/index.ts @@ -13,6 +13,7 @@ import { CUSTOM_LOCAL_AUTH_MARKER, normalizeOptionalSecretInput, } from "openclaw/plugin-sdk/provider-auth"; +import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { lmstudioMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js"; import { LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, @@ -216,6 +217,7 @@ export default definePluginEntry({ ?.find((model) => model.id === ctx.modelId), augmentModelCatalog: (ctx) => resolveLmstudioAugmentedCatalogEntries(ctx.config), wrapStreamFn: wrapLmstudioInferencePreload, + ...buildProviderToolCompatFamilyHooks("llamacpp-gbnf"), wizard: { setup: { choiceId: PROVIDER_ID, diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 3b1fb3dbf7c9..6a9f2ba18ccd 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -144,6 +144,17 @@ function registerOllamaCloudProvider() { return registerProvidersWithPluginConfig({}).find((provider) => provider.id === "ollama-cloud"); } +describe("ollama tool-schema compatibility", () => { + it("registers llama.cpp GBNF projection for local and cloud providers", () => { + for (const provider of registerProvidersWithPluginConfig({})) { + expect(provider).toMatchObject({ + normalizeToolSchemas: expect.any(Function), + inspectToolSchemas: expect.any(Function), + }); + } + }); +}); + function createOllamaResetValidationContext( opts: Record = {}, ): Parameters>[0] { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 41d614908afc..d2a8abeecf7b 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -31,6 +31,7 @@ import { buildOpenAICompatibleReplayPolicy, selectPreferredLocalModelId, } from "openclaw/plugin-sdk/provider-model-shared"; +import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime"; import { buildOllamaModelDefinition, @@ -714,6 +715,7 @@ async function augmentConfiguredOllamaCatalogModels(params: { // Local and cloud own distinct auth/catalog policy but share native transport and replay rules. const OLLAMA_SHARED_PROVIDER_HOOKS = { + ...buildProviderToolCompatFamilyHooks("llamacpp-gbnf"), createStreamFn: ({ config, model, provider }) => { if (model.api !== "ollama") { return undefined; @@ -739,6 +741,8 @@ const OLLAMA_SHARED_PROVIDER_HOOKS = { } satisfies Pick< ProviderPlugin, | "createStreamFn" + | "normalizeToolSchemas" + | "inspectToolSchemas" | "buildReplayPolicy" | "resolveReasoningOutputMode" | "resolveThinkingProfile" diff --git a/packages/ai/src/internal/openai.ts b/packages/ai/src/internal/openai.ts index eae8b23ae1d0..274403268011 100644 --- a/packages/ai/src/internal/openai.ts +++ b/packages/ai/src/internal/openai.ts @@ -2,6 +2,7 @@ export * from "../providers/agent-tools-parameter-schema.js"; export * from "../providers/azure-deployment-map.js"; export * from "../providers/azure-openai-responses-client-compat.js"; export * from "../providers/clean-for-gemini.js"; +export * from "../providers/clean-for-llamacpp-gbnf.js"; export * from "../providers/openai-completions.js"; export * from "../providers/openai-prompt-cache.js"; export * from "../providers/openai-reasoning-effort.js"; diff --git a/packages/ai/src/providers/agent-tools-parameter-schema.ts b/packages/ai/src/providers/agent-tools-parameter-schema.ts index d770f07c0e43..9c44d26a73b8 100644 --- a/packages/ai/src/providers/agent-tools-parameter-schema.ts +++ b/packages/ai/src/providers/agent-tools-parameter-schema.ts @@ -11,6 +11,7 @@ import { } from "@openclaw/normalization-core/string-normalization"; import type { TSchema } from "typebox"; import { cleanSchemaForGemini } from "./clean-for-gemini.js"; +import { cleanSchemaForLlamacppGbnf } from "./clean-for-llamacpp-gbnf.js"; import { stripUnsupportedSchemaKeywords } from "./schema-keyword-strip.js"; /** @@ -831,12 +832,16 @@ function normalizeToolParameterSchemaUncached( const isAnthropicProvider = normalizedProvider.includes("anthropic"); const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat); const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat); + const isLlamacppGbnfProfile = normalizedToolSchemaProfile === "llamacpp"; function applyProviderCleaning(s: unknown): TSchema { const normalizedSchema = normalizeArraySchemasMissingItems(s); - const arrayItemsCompatibleSchema = omitEmptyArrayItems + let arrayItemsCompatibleSchema = omitEmptyArrayItems ? stripEmptyArrayItemsFromArraySchemas(normalizedSchema) : normalizedSchema; + if (isLlamacppGbnfProfile) { + arrayItemsCompatibleSchema = cleanSchemaForLlamacppGbnf(arrayItemsCompatibleSchema); + } if (isGeminiProvider && !isAnthropicProvider) { const geminiCompatibleSchema = cleanSchemaForGemini(arrayItemsCompatibleSchema); return unsupportedToolSchemaKeywords.size > 0 diff --git a/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts b/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts new file mode 100644 index 000000000000..6cb6a1835228 --- /dev/null +++ b/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts @@ -0,0 +1,126 @@ +/** llama.cpp rejects grammar repetitions whose expanded rule count reaches 2000. */ +export const LLAMACPP_GBNF_MAX_REPETITION_THRESHOLD = 2000; + +const SCHEMA_MAP_KEYS = new Set([ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", +]); + +const SCHEMA_CHILD_KEYS = new Set([ + "additionalItems", + "additionalProperties", + "allOf", + "anyOf", + "contains", + "else", + "if", + "items", + "not", + "oneOf", + "prefixItems", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", +]); + +function isSchemaRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function cleanSchemaNode(node: unknown): unknown { + if (Array.isArray(node)) { + let changed = false; + const entries = node.map((entry) => { + const next = cleanSchemaNode(entry); + changed ||= next !== entry; + return next; + }); + return changed ? entries : node; + } + if (!isSchemaRecord(node)) { + return node; + } + + let changed = false; + const cleaned: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key === "pattern") { + changed = true; + continue; + } + if ( + key === "maxLength" && + typeof value === "number" && + value >= LLAMACPP_GBNF_MAX_REPETITION_THRESHOLD + ) { + changed = true; + continue; + } + + let next = value; + if (SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) { + let mapChanged = false; + next = Object.fromEntries( + Object.entries(value).map(([childKey, childValue]) => { + const cleanedChild = cleanSchemaNode(childValue); + mapChanged ||= cleanedChild !== childValue; + return [childKey, cleanedChild]; + }), + ); + if (!mapChanged) { + next = value; + } + } else if (SCHEMA_CHILD_KEYS.has(key)) { + next = cleanSchemaNode(value); + } + cleaned[key] = next; + changed ||= next !== value; + } + return changed ? cleaned : node; +} + +function collectSchemaViolations(node: unknown, path: string, violations: string[]): void { + if (Array.isArray(node)) { + node.forEach((entry, index) => collectSchemaViolations(entry, `${path}[${index}]`, violations)); + return; + } + if (!isSchemaRecord(node)) { + return; + } + + if ("pattern" in node) { + violations.push(`${path}.pattern`); + } + if ( + typeof node.maxLength === "number" && + node.maxLength >= LLAMACPP_GBNF_MAX_REPETITION_THRESHOLD + ) { + violations.push(`${path}.maxLength`); + } + + for (const [key, value] of Object.entries(node)) { + if (SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) { + for (const [childKey, childValue] of Object.entries(value)) { + collectSchemaViolations(childValue, `${path}.${key}.${childKey}`, violations); + } + } else if (SCHEMA_CHILD_KEYS.has(key)) { + collectSchemaViolations(value, `${path}.${key}`, violations); + } + } +} + +/** Removes JSON Schema constraints that llama.cpp cannot compile into GBNF. */ +export function cleanSchemaForLlamacppGbnf(schema: unknown): unknown { + return cleanSchemaNode(schema); +} + +/** Reports schema paths that llama.cpp cannot compile into GBNF. */ +export function findLlamacppGbnfSchemaViolations(schema: unknown, path: string): string[] { + const violations: string[] = []; + collectSchemaViolations(schema, path, violations); + return violations; +} diff --git a/src/agents/agent-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts index 4b64ff793194..aac725f55145 100644 --- a/src/agents/agent-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -135,6 +135,34 @@ describe("normalizeToolParameterSchema", () => { }); }); + it("applies llama.cpp cleaning only for the explicit tool-schema profile", () => { + const schema = { + type: "object", + properties: { + declarationKey: { type: "string", pattern: "^\\S+$", maxLength: 200 }, + safe: { type: "string", maxLength: 1999 }, + boundary: { type: "string", maxLength: 2000 }, + script: { type: "string", minLength: 1, maxLength: 65_536 }, + }, + }; + + expect(normalizeToolParameterSchema(schema, { modelProvider: "openai" })).toEqual(schema); + expect( + normalizeToolParameterSchema(schema, { + modelProvider: "openai-compatible", + modelCompat: { toolSchemaProfile: "llamacpp" }, + }), + ).toEqual({ + type: "object", + properties: { + declarationKey: { type: "string", maxLength: 200 }, + safe: { type: "string", maxLength: 1999 }, + boundary: { type: "string" }, + script: { type: "string", minLength: 1 }, + }, + }); + }); + it("applies explicit unsupported keyword stripping after Gemini cleanup", () => { expect( normalizeToolParameterSchema( diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index f65685d004e2..833eeafd9077 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -1,4 +1,7 @@ -import { normalizeToolParameterSchema } from "@openclaw/ai/internal/openai"; +import { + findLlamacppGbnfSchemaViolations, + normalizeToolParameterSchema, +} from "@openclaw/ai/internal/openai"; // Cron tool schema tests cover the provider-facing parameter shape and runtime // validation compatibility for cron jobs. import { Value } from "typebox/value"; @@ -38,6 +41,9 @@ describe("createCronToolSchema", () => { modelProvider: "jjcc", modelId: "gemini-3.1-pro-preview", }) as unknown as Record; + const llamacppSchemaRecord = normalizeToolParameterSchema(schema, { + modelCompat: { toolSchemaProfile: "llamacpp" }, + }) as unknown as Record; // Regression: models like GPT-5.4 rely on these fields to populate job/patch. // If a field is removed from this list the test must be updated intentionally. @@ -75,6 +81,19 @@ describe("createCronToolSchema", () => { expect(declarationKey).not.toHaveProperty("pattern"); }); + it("projects the complete cron schema into llama.cpp's GBNF subset", () => { + expect(propertyAt(schemaRecord, "job.trigger.script")).toMatchObject({ + type: "string", + minLength: 1, + maxLength: 65_536, + }); + expect(propertyAt(llamacppSchemaRecord, "job.trigger.script")).toEqual({ + type: "string", + minLength: 1, + }); + expect(findLlamacppGbnfSchemaViolations(llamacppSchemaRecord, "cron.parameters")).toEqual([]); + }); + it("patch exposes the expected top-level fields", () => { expect(keysAt(schemaRecord, "patch")).toEqual( [ diff --git a/src/plugin-sdk/provider-tools.test.ts b/src/plugin-sdk/provider-tools.test.ts index a0054b18927a..7b9491d018a1 100644 --- a/src/plugin-sdk/provider-tools.test.ts +++ b/src/plugin-sdk/provider-tools.test.ts @@ -5,9 +5,11 @@ import { inspectDeepSeekToolSchemas, findOpenAIStrictSchemaViolations, inspectGeminiToolSchemas, + inspectLlamacppGbnfToolSchemas, inspectOpenAIToolSchemas, normalizeDeepSeekToolSchemas, normalizeGeminiToolSchemas, + normalizeLlamacppGbnfToolSchemas, normalizeOpenAIToolSchemas, } from "./provider-tools.js"; @@ -42,6 +44,11 @@ describe("buildProviderToolCompatFamilyHooks", () => { normalizeToolSchemas: normalizeGeminiToolSchemas, inspectToolSchemas: inspectGeminiToolSchemas, }, + { + family: "llamacpp-gbnf" as const, + normalizeToolSchemas: normalizeLlamacppGbnfToolSchemas, + inspectToolSchemas: inspectLlamacppGbnfToolSchemas, + }, { family: "openai" as const, normalizeToolSchemas: normalizeOpenAIToolSchemas, @@ -57,6 +64,74 @@ describe("buildProviderToolCompatFamilyHooks", () => { } }); + it("removes llama.cpp GBNF-hostile constraints from nested tool schemas", () => { + const hooks = buildProviderToolCompatFamilyHooks("llamacpp-gbnf"); + const tools = [ + { + name: "cron", + description: "", + parameters: { + type: "object", + properties: { + job: { + type: "object", + properties: { + declarationKey: { + type: "string", + maxLength: 1999, + pattern: "^\\S+$", + }, + trigger: { + anyOf: [ + { + type: "object", + properties: { + script: { type: "string", minLength: 1, maxLength: 65_536 }, + boundary: { type: "string", maxLength: 2000 }, + }, + }, + { type: "null" }, + ], + }, + }, + }, + }, + }, + }, + ] as never; + + const normalized = hooks.normalizeToolSchemas({ + provider: "ollama", + modelId: "qwen3.5", + tools, + }); + + expect(normalized[0]?.parameters).toEqual({ + type: "object", + properties: { + job: { + type: "object", + properties: { + declarationKey: { type: "string", maxLength: 1999 }, + trigger: { + anyOf: [ + { + type: "object", + properties: { + script: { type: "string", minLength: 1 }, + boundary: { type: "string" }, + }, + }, + { type: "null" }, + ], + }, + }, + }, + }, + }); + expect(hooks.inspectToolSchemas({ provider: "ollama", tools: normalized })).toEqual([]); + }); + it("normalizes canonical OpenAI Codex Responses tool schemas", () => { const hooks = buildProviderToolCompatFamilyHooks("openai"); const tools = [{ name: "demo", description: "", parameters: {} }] as never; diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index ce58dad6857c..47ebfbba41ad 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -1,5 +1,7 @@ import { cleanSchemaForGemini, + cleanSchemaForLlamacppGbnf, + findLlamacppGbnfSchemaViolations, findOpenAIStrictSchemaViolations, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, normalizeOpenAIStrictCompatSchema, @@ -15,6 +17,8 @@ import type { export { cleanSchemaForGemini, + cleanSchemaForLlamacppGbnf, + findLlamacppGbnfSchemaViolations, findOpenAIStrictSchemaViolations, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, stripUnsupportedSchemaKeywords, @@ -106,6 +110,34 @@ export function inspectGeminiToolSchemas( }); } +/** Rewrites tool schemas into the JSON Schema subset accepted by llama.cpp GBNF. */ +export function normalizeLlamacppGbnfToolSchemas( + ctx: ProviderNormalizeToolSchemasContext, +): AnyAgentTool[] { + return ctx.tools.map((tool) => { + if (!tool.parameters || typeof tool.parameters !== "object") { + return tool; + } + const parameters = cleanSchemaForLlamacppGbnf(tool.parameters); + return parameters === tool.parameters + ? tool + : { + ...tool, + parameters: parameters as TSchema, + }; + }); +} + +/** Reports tool-schema constraints that llama.cpp GBNF cannot compile. */ +export function inspectLlamacppGbnfToolSchemas( + ctx: ProviderNormalizeToolSchemasContext, +): ProviderToolSchemaDiagnostic[] { + return ctx.tools.flatMap((tool, toolIndex) => { + const violations = findLlamacppGbnfSchemaViolations(tool.parameters, `${tool.name}.parameters`); + return violations.length > 0 ? [{ toolName: tool.name, toolIndex, violations }] : []; + }); +} + /** * Rewrites OpenAI-native tool schemas to satisfy strict object-schema requirements. */ @@ -327,7 +359,7 @@ export function inspectDeepSeekToolSchemas( /** * Supported provider tool-schema compatibility families. */ -export type ProviderToolCompatFamily = "deepseek" | "gemini" | "openai"; +export type ProviderToolCompatFamily = "deepseek" | "gemini" | "llamacpp-gbnf" | "openai"; /** * Returns the normalizer and inspector pair for a provider tool-schema compatibility family. @@ -352,6 +384,11 @@ export function buildProviderToolCompatFamilyHooks( normalizeToolSchemas: normalizeGeminiToolSchemas, inspectToolSchemas: inspectGeminiToolSchemas, }; + case "llamacpp-gbnf": + return { + normalizeToolSchemas: normalizeLlamacppGbnfToolSchemas, + inspectToolSchemas: inspectLlamacppGbnfToolSchemas, + }; case "openai": return { normalizeToolSchemas: normalizeOpenAIToolSchemas,