fix(agents): project llama.cpp-safe tool schemas (#115598)

Co-authored-by: Jithin Mohandas <mohandasjithin@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-29 01:33:32 -04:00
committed by GitHub
parent 48b3a28edc
commit 98a066c90e
13 changed files with 322 additions and 3 deletions
+2
View File
@@ -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" })],
}),
);
+2
View File
@@ -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,
+7
View File
@@ -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,
+2
View File
@@ -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,
+11
View File
@@ -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<string, unknown> = {},
): Parameters<NonNullable<ProviderAuthMethod["validateNonInteractive"]>>[0] {
+4
View File
@@ -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"
+1
View File
@@ -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";
@@ -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
@@ -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<string, unknown> {
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<string, unknown> = {};
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;
}
+28
View File
@@ -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(
+20 -1
View File
@@ -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<string, unknown>;
const llamacppSchemaRecord = normalizeToolParameterSchema(schema, {
modelCompat: { toolSchemaProfile: "llamacpp" },
}) as unknown as Record<string, unknown>;
// 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(
[
+75
View File
@@ -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;
+38 -1
View File
@@ -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,