feat: enhance function calling with richer schemas, validation, parallel execution, and Ollama native support (#51)

* feat: add richer JSON Schema types and AJV validation utility

- Extended ToolParameter with enum, items, properties, additionalProperties (max depth 3)
- Added ToolError class with ToolErrorCategory enum
- Added ToolValidationError interface
- Created AJV-based validateToolArgs() with lazy loading and schema depth enforcement
- 57 new tests covering types and validation

* feat: add validation gate, parallel execution, and structured error feedback

- Arsenal.execute() validates args against JSON Schema before handler execution
- Tool execution errors categorized (scope_denied, tool_not_found, timeout, execution_error, validation_error)
- ToolError import fix in agent (was missing, causing silent ReferenceError)
- Semaphore-based parallel tool execution with configurable concurrency (default 5)
- Results maintain original call order regardless of completion order
- Structured error feedback to LLM with category only (no raw error messages)
- Scope/approval gates checked per-call before execution
- 35 new tests covering validation, parallel execution, and error feedback

* feat: add Ollama native function calling with probe + cache + fallback

- Added nativeTools config option to LLMConfig
- LocalAdapter probes model support for native tools on first call
- Probe result cached per model name to avoid re-probing
- Native tools formatted as Ollama /api/chat tools parameter
- Tool calls parsed from Ollama's tool_calls response format
- Falls back to text-based renderToolContract when native not supported
- OpenAI-compatible wire format also supported (for vLLM, LM Studio)
- 10 new tests covering probe, cache, fallback, and config overrides

* fix(tools): harden validation and native fallback

---------

Co-authored-by: sronaal <sronaal@users.noreply.github.com>
Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-07-20 11:45:52 -04:00
1 parent 151d3555c6
commit df08627cb9
14 files changed
+3019 -63

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
/**
* Schema Validation Utility
*
* AJV-based argument validation for tool calls with schema depth enforcement.
* AJV is lazy-loaded on first call to keep startup fast.
*/
import type { ToolParameter, ToolValidationError } from '../types/index.js';
import { createRequire } from 'node:module';
// Lazy-loaded AJV instance
let ajv: any; // eslint-disable-line @typescript-eslint/no-explicit-any
const require = createRequire(import.meta.url);
function getAjv(): any { // eslint-disable-line @typescript-eslint/no-explicit-any
if (!ajv) {
const AjvModule = require('ajv');
ajv = new AjvModule.default({
allErrors: true,
strict: false,
useDefaults: true,
});
}
return ajv;
}
const MAX_SCHEMA_DEPTH = 3;
/**
* Build a JSON Schema object from ToolParameter[] definition.
* Converts the T3MP3ST parameter format to standard JSON Schema.
*/
export function buildJsonSchema(params: ToolParameter[]): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const param of params) {
const prop: Record<string, unknown> = {
type: param.type,
description: param.description,
};
if (param.enum !== undefined) {
prop.enum = param.enum;
}
if (param.default !== undefined) {
prop.default = param.default;
}
if (param.type === 'array' && param.items) {
prop.items = buildItemsSchema(param.items);
}
if (param.type === 'object' && param.properties) {
const nested = buildObjectProperties(param.properties);
prop.properties = nested.properties;
if (nested.required.length > 0) {
prop.required = nested.required;
}
if (param.additionalProperties === false) {
prop.additionalProperties = false;
}
}
if (param.required) {
required.push(param.name);
}
properties[param.name] = prop;
}
const schema: Record<string, unknown> = {
type: 'object',
properties,
};
if (required.length > 0) {
schema.required = required;
}
return schema;
}
/**
* Build properties schema for object parameters (recursive).
*/
function buildObjectProperties(
properties: Record<string, ToolParameter>,
): { properties: Record<string, unknown>; required: string[] } {
const props: Record<string, unknown> = {};
const required: string[] = [];
for (const [key, param] of Object.entries(properties)) {
const prop: Record<string, unknown> = {
type: param.type,
description: param.description,
};
if (param.enum !== undefined) {
prop.enum = param.enum;
}
if (param.default !== undefined) {
prop.default = param.default;
}
if (param.type === 'array' && param.items) {
prop.items = buildItemsSchema(param.items);
}
if (param.type === 'object' && param.properties) {
const nested = buildObjectProperties(param.properties);
prop.properties = nested.properties;
if (nested.required.length > 0) {
prop.required = nested.required;
}
if (param.additionalProperties === false) {
prop.additionalProperties = false;
}
}
if (param.required) {
required.push(key);
}
props[key] = prop;
}
return { properties: props, required };
}
/**
* Build items schema for array parameters.
*/
function buildItemsSchema(items: ToolParameter['items']): Record<string, unknown> {
if (!items) return { type: 'string' };
const schema: Record<string, unknown> = {
type: items.type,
};
if (items.description) {
schema.description = items.description;
}
if (items.properties) {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [key, param] of Object.entries(items.properties)) {
properties[key] = {
type: param.type,
description: param.description,
};
if (param.enum !== undefined) {
(properties[key] as Record<string, unknown>).enum = param.enum;
}
if (param.required) {
required.push(key);
}
}
schema.properties = properties;
if (required.length > 0) {
schema.required = required;
}
}
return schema;
}
/**
* Enforce maximum schema depth. Throws if depth exceeds limit.
* Called at tool registration time to prevent overly complex schemas.
* Depth counts object/array nesting levels — leaf properties don't add depth.
*/
export function assertSchemaDepth(params: ToolParameter[], maxDepth: number = MAX_SCHEMA_DEPTH): void {
function getMaxDepth(param: ToolParameter, current: number): number {
if (param.type === 'object' && param.properties) {
// Each nested object property adds 1 to depth
let maxNested = current;
for (const nested of Object.values(param.properties)) {
// For object/array children, they are at current+1 depth
// For leaf types, they stay at current depth
const childDepth = (nested.type === 'object' || nested.type === 'array')
? getMaxDepth(nested, current + 1)
: current;
maxNested = Math.max(maxNested, childDepth);
}
return maxNested;
}
if (param.type === 'array' && param.items?.properties) {
// Array items type is at current+1; items' properties are at current+2
let maxNested = current;
for (const nested of Object.values(param.items.properties)) {
const childDepth = (nested.type === 'object' || nested.type === 'array')
? getMaxDepth(nested, current + 2)
: current + 1;
maxNested = Math.max(maxNested, childDepth);
}
return maxNested;
}
// Leaf types (string, number, boolean) don't add depth
return current;
}
for (const param of params) {
const depth = getMaxDepth(param, 1);
if (depth > maxDepth) {
throw new Error(
`Schema depth ${depth} exceeds maximum ${maxDepth} for parameter "${param.name}"`,
);
}
}
}
// Compiled schema cache to avoid re-compiling the same schemas
const compiledSchemas = new Map<string, ReturnType<typeof getAjv>['compile']>();
function coerceModelScalars(
values: Record<string, unknown>,
schema: ToolParameter[],
): void {
for (const param of schema) {
const value = values[param.name];
if (typeof value === 'string' && param.type === 'number') {
const trimmed = value.trim();
if (trimmed !== '' && Number.isFinite(Number(trimmed))) {
values[param.name] = Number(trimmed);
}
} else if (typeof value === 'string' && param.type === 'boolean') {
if (value === 'true') values[param.name] = true;
else if (value === 'false') values[param.name] = false;
} else if (value && typeof value === 'object' && !Array.isArray(value) && param.properties) {
coerceModelScalars(
value as Record<string, unknown>,
Object.entries(param.properties).map(([name, nested]) => ({ ...nested, name })),
);
} else if (Array.isArray(value) && param.items?.properties) {
const itemSchema = Object.entries(param.items.properties)
.map(([name, nested]) => ({ ...nested, name }));
for (const item of value) {
if (item && typeof item === 'object' && !Array.isArray(item)) {
coerceModelScalars(item as Record<string, unknown>, itemSchema);
}
}
}
}
}
/**
* Validate tool arguments against a parameter schema.
* Returns an array of validation errors (empty if valid).
*
* @param toolName - Name of the tool being validated (for error messages)
* @param args - The arguments to validate
* @param schema - The tool's parameter schema
* @returns Array of validation errors, empty if valid
*/
export function validateToolArgs(
_toolName: string,
args: Record<string, unknown>,
schema: ToolParameter[],
): ToolValidationError[] {
// No schema → no validation needed
if (!schema || schema.length === 0) {
return [];
}
// Local/text models commonly serialize numeric and boolean schema values as
// strings. Coerce only those safe directions; never stringify malformed
// numbers or objects to make an invalid call appear valid.
coerceModelScalars(args, schema);
const jsonSchema = buildJsonSchema(schema);
const schemaKey = JSON.stringify(jsonSchema);
let validate: any; // eslint-disable-line @typescript-eslint/no-explicit-any
if (compiledSchemas.has(schemaKey)) {
validate = compiledSchemas.get(schemaKey);
} else {
const ajvInstance = getAjv();
validate = ajvInstance.compile(jsonSchema);
compiledSchemas.set(schemaKey, validate);
}
const valid = validate(args);
if (valid) {
return [];
}
const errors: ToolValidationError[] = [];
for (const error of validate.errors || []) {
const field = error.instancePath
? error.instancePath.replace(/^\//, '').replace(/\//g, '.')
: error.params?.missingProperty || error.params?.additionalProperty || 'unknown';
errors.push({
field,
message: error.message || 'Validation failed',
received: getFieldValue(args, field),
expected: formatExpected(error),
});
}
return errors;
}
/**
* Get the value at a dotted path in an object.
*/
function getFieldValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Format expected value from AJV error.
*/
function formatExpected(error: any): string { // eslint-disable-line @typescript-eslint/no-explicit-any
if (error.keyword === 'type') {
return error.params?.type || 'unknown type';
}
if (error.keyword === 'enum') {
return `one of: ${(error.params?.allowedValues || []).join(', ')}`;
}
if (error.keyword === 'required') {
return `required field "${error.params?.missingProperty || ''}"`;
}
if (error.keyword === 'additionalProperties') {
return `no additional properties allowed (found "${error.params?.additionalProperty || ''}")`;
}
return error.message || 'valid value';
}