feat(shared-ai): toolToFunctionSchema — catalog → OpenAI function-spec

Single bridge between the AI_TOOL_CATALOG shape and the wire format every
provider (Gemini, OpenAI-compat, Ollama ≥ 0.3) speaks for native tool
calling. Keeps the catalog as the source of truth — the runner never
reads catalog entries directly; it asks this converter for function-spec
shapes to hand the LLM.

- No _rationale or wrapper-tool injection: the runner doesn't need it
  and the added schema noise would hurt planner quality.
- Throws on unknown parameter types so catalog typos (e.g. "array"
  instead of "string") fail loudly instead of coercing silently.
- Preserves enum constraints; drops the enum key entirely when absent
  so Gemini doesn't reject empty-enum function-declarations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 15:24:36 +02:00
parent e757470cb0
commit 4523ab24e3
4 changed files with 235 additions and 2 deletions

View file

@ -75,8 +75,19 @@ export {
type PolicyDecision,
} from './policy';
export type { ToolSchema } from './tools';
export { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './tools';
export type {
FunctionSpec,
JsonSchemaObject,
JsonSchemaProperty,
ToolSchema,
ToolSpec,
} from './tools';
export {
AI_TOOL_CATALOG,
AI_TOOL_CATALOG_BY_NAME,
toolToFunctionSchema,
toolsToFunctionSchemas,
} from './tools';
export type {
Guardrail,

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { AI_TOOL_CATALOG } from './schemas';
import { toolToFunctionSchema, toolsToFunctionSchemas } from './function-schema';
import type { ToolSchema } from './schemas';
describe('toolToFunctionSchema', () => {
it('converts a minimal tool with no parameters', () => {
const tool: ToolSchema = {
name: 'ping',
module: 'test',
description: 'Test tool',
defaultPolicy: 'auto',
parameters: [],
};
expect(toolToFunctionSchema(tool)).toEqual({
type: 'function',
function: {
name: 'ping',
description: 'Test tool',
parameters: {
type: 'object',
properties: {},
required: [],
},
},
});
});
it('maps parameter types and marks required fields', () => {
const tool: ToolSchema = {
name: 'create_thing',
module: 'test',
description: 'Create a thing',
defaultPolicy: 'propose',
parameters: [
{ name: 'title', type: 'string', description: 'Title', required: true },
{ name: 'count', type: 'number', description: 'Count', required: false },
{ name: 'enabled', type: 'boolean', description: 'Flag', required: false },
],
};
const schema = toolToFunctionSchema(tool);
expect(schema.function.parameters.properties.title).toEqual({
type: 'string',
description: 'Title',
});
expect(schema.function.parameters.properties.count.type).toBe('number');
expect(schema.function.parameters.properties.enabled.type).toBe('boolean');
expect(schema.function.parameters.required).toEqual(['title']);
});
it('preserves enum when present', () => {
const tool: ToolSchema = {
name: 'pick',
module: 'test',
description: 'Pick one',
defaultPolicy: 'auto',
parameters: [
{
name: 'color',
type: 'string',
description: 'Color',
required: true,
enum: ['red', 'blue', 'green'],
},
],
};
const schema = toolToFunctionSchema(tool);
expect(schema.function.parameters.properties.color.enum).toEqual(['red', 'blue', 'green']);
});
it('does not add an enum key when the parameter has none', () => {
const tool: ToolSchema = {
name: 'x',
module: 'test',
description: 'X',
defaultPolicy: 'auto',
parameters: [{ name: 'y', type: 'string', description: 'Y', required: true }],
};
const prop = toolToFunctionSchema(tool).function.parameters.properties.y;
expect('enum' in prop).toBe(false);
});
it('throws on unknown parameter types (catalog typo guard)', () => {
const tool: ToolSchema = {
name: 'broken',
module: 'test',
description: 'Broken',
defaultPolicy: 'auto',
parameters: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ name: 'p', type: 'array' as any, description: 'p', required: true },
],
};
expect(() => toolToFunctionSchema(tool)).toThrow(/Unsupported parameter type/);
});
});
describe('toolsToFunctionSchemas', () => {
it('round-trips the whole catalog without throwing', () => {
const schemas = toolsToFunctionSchemas(AI_TOOL_CATALOG);
expect(schemas.length).toBe(AI_TOOL_CATALOG.length);
for (const s of schemas) {
expect(s.type).toBe('function');
expect(s.function.name).toMatch(/^[a-z_]+$/);
expect(s.function.parameters.type).toBe('object');
}
});
it('produces globally unique function names (matches catalog)', () => {
const schemas = toolsToFunctionSchemas(AI_TOOL_CATALOG);
const names = schemas.map((s) => s.function.name);
expect(new Set(names).size).toBe(names.length);
});
});

View file

@ -0,0 +1,101 @@
/**
* Converts AI_TOOL_CATALOG entries to OpenAI-spec function schemas.
*
* Every provider we support (Google Gemini, OpenAI, OpenRouter, Groq,
* Together, Ollama 0.3+) speaks the same `{ type: "function", function:
* { name, description, parameters } }` shape for tool declarations.
* Parameters are JSON Schema. This converter is the single bridge
* between our catalog format and the wire format changing the catalog
* shape only needs this file (plus tests) to be updated.
*
* The converter keeps tools lean: no `_rationale` meta-parameter, no
* wrapper objects, no provider-specific tweaks. Identifying calls to
* a specific policy decision (propose/auto/deny) is the executor's job,
* not the model's.
*/
import type { ToolSchema } from './schemas';
/** OpenAI-compatible JSON-Schema property type. */
export interface JsonSchemaProperty {
type: 'string' | 'number' | 'integer' | 'boolean';
description?: string;
enum?: readonly string[];
}
/** OpenAI-compatible JSON-Schema object wrapper. */
export interface JsonSchemaObject {
type: 'object';
properties: Record<string, JsonSchemaProperty>;
required: string[];
}
/** OpenAI-compatible function declaration. */
export interface FunctionSpec {
name: string;
description: string;
parameters: JsonSchemaObject;
}
/** OpenAI-compatible tool entry (only `function` tools are supported). */
export interface ToolSpec {
type: 'function';
function: FunctionSpec;
}
/** Map a ToolSchema parameter type ("string" | "number" | "boolean") to
* a JSON Schema type. Catalog values are already narrow enough to pass
* through, but we centralise the translation here for future-proofing
* (e.g. if we ever introduce `"integer"` as a distinct type). */
function mapParamType(t: string): JsonSchemaProperty['type'] {
switch (t) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
return t;
default:
// Unknown types in the catalog are a bug, but don't silently
// coerce to string — that would mask the mistake.
throw new Error(`Unsupported parameter type in tool catalog: "${t}"`);
}
}
/** Convert a single ToolSchema into an OpenAI-spec ToolSpec. */
export function toolToFunctionSchema(tool: ToolSchema): ToolSpec {
const properties: Record<string, JsonSchemaProperty> = {};
const required: string[] = [];
for (const param of tool.parameters) {
const prop: JsonSchemaProperty = {
type: mapParamType(param.type),
description: param.description,
};
if (param.enum && param.enum.length > 0) {
prop.enum = param.enum;
}
properties[param.name] = prop;
if (param.required) {
required.push(param.name);
}
}
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: {
type: 'object',
properties,
required,
},
},
};
}
/** Convert a full tool list to OpenAI-spec entries in one call.
* Handy for building the `tools` field on a chat-completion request. */
export function toolsToFunctionSchemas(tools: readonly ToolSchema[]): ToolSpec[] {
return tools.map(toolToFunctionSchema);
}

View file

@ -1,2 +1,9 @@
export type { ToolSchema } from './schemas';
export { AI_TOOL_CATALOG, AI_TOOL_CATALOG_BY_NAME } from './schemas';
export type {
FunctionSpec,
JsonSchemaObject,
JsonSchemaProperty,
ToolSpec,
} from './function-schema';
export { toolToFunctionSchema, toolsToFunctionSchemas } from './function-schema';