From 4523ab24e3e933788276459de0837f6f23559863 Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 20 Apr 2026 15:24:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared-ai):=20toolToFunctionSchema=20?= =?UTF-8?q?=E2=80=94=20catalog=20=E2=86=92=20OpenAI=20function-spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/shared-ai/src/index.ts | 15 ++- .../src/tools/function-schema.test.ts | 114 ++++++++++++++++++ .../shared-ai/src/tools/function-schema.ts | 101 ++++++++++++++++ packages/shared-ai/src/tools/index.ts | 7 ++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 packages/shared-ai/src/tools/function-schema.test.ts create mode 100644 packages/shared-ai/src/tools/function-schema.ts diff --git a/packages/shared-ai/src/index.ts b/packages/shared-ai/src/index.ts index 47ec9cac1..310b06285 100644 --- a/packages/shared-ai/src/index.ts +++ b/packages/shared-ai/src/index.ts @@ -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, diff --git a/packages/shared-ai/src/tools/function-schema.test.ts b/packages/shared-ai/src/tools/function-schema.test.ts new file mode 100644 index 000000000..23c499a65 --- /dev/null +++ b/packages/shared-ai/src/tools/function-schema.test.ts @@ -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); + }); +}); diff --git a/packages/shared-ai/src/tools/function-schema.ts b/packages/shared-ai/src/tools/function-schema.ts new file mode 100644 index 000000000..c672b3cde --- /dev/null +++ b/packages/shared-ai/src/tools/function-schema.ts @@ -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; + 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 = {}; + 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); +} diff --git a/packages/shared-ai/src/tools/index.ts b/packages/shared-ai/src/tools/index.ts index b5201adf4..db20bd5dc 100644 --- a/packages/shared-ai/src/tools/index.ts +++ b/packages/shared-ai/src/tools/index.ts @@ -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';