mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +02:00
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:
parent
e757470cb0
commit
4523ab24e3
4 changed files with 235 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
114
packages/shared-ai/src/tools/function-schema.test.ts
Normal file
114
packages/shared-ai/src/tools/function-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
101
packages/shared-ai/src/tools/function-schema.ts
Normal file
101
packages/shared-ai/src/tools/function-schema.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue