mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(agents): project llama.cpp-safe tool schemas (#115598)
Co-authored-by: Jithin Mohandas <mohandasjithin@gmail.com>
This commit is contained in:
committed by
GitHub
parent
48b3a28edc
commit
98a066c90e
@@ -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" })],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user