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

+156 -12
View File
@@ -113,7 +113,16 @@ interface AnthropicResponse {
}
interface OllamaResponse {
message?: { content?: string };
message?: {
content?: string;
/** Native function calls returned by the model (Ollama /api/chat with tools). */
tool_calls?: Array<{
function: {
name: string;
arguments: string | Record<string, unknown>;
};
}>;
};
model?: string;
prompt_eval_count?: number;
eval_count?: number;
@@ -748,6 +757,89 @@ class LocalAdapter implements LLMProviderAdapter {
return { valid: true };
}
/** Per-model probe cache: true = native tools supported, false = not. */
private static modelToolSupport = new Map<string, boolean>();
/** @internal Reset the probe cache — used by tests. */
static __resetProbeCache(): void {
LocalAdapter.modelToolSupport.clear();
}
private getModelName(): string {
return this.config.model || 'default';
}
/** Whether to try native function-calling on the current request. */
private shouldTryNativeTools(): boolean {
if (this.config.nativeTools === false) return false;
if (this.config.nativeTools === true) return true;
const name = this.getModelName();
if (LocalAdapter.modelToolSupport.has(name)) {
return LocalAdapter.modelToolSupport.get(name)!;
}
// Unknown — optimistic: try native on the first call and cache the result.
return true;
}
private cacheProbeResult(supported: boolean): void {
const name = this.getModelName();
if (!LocalAdapter.modelToolSupport.has(name)) {
LocalAdapter.modelToolSupport.set(name, supported);
}
}
/** Format tools for the Ollama /api/chat wire. */
private formatOllamaTools(tools: LLMToolDefinition[]): Record<string, unknown>[] {
return tools.map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
/** Format tools for the OpenAI-compatible /v1/chat/completions wire. */
private formatOpenAITools(tools: LLMToolDefinition[]): Record<string, unknown>[] {
return tools.map(t => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
}));
}
/** Parse native tool_calls from an Ollama /api/chat response. */
private parseOllamaNativeToolCalls(data: OllamaResponse): LLMToolCall[] | undefined {
const raw = data.message?.tool_calls;
if (!raw?.length) return undefined;
return raw.map((tc, i) => {
let args: Record<string, unknown> = {};
if (tc.function.arguments && typeof tc.function.arguments === 'object') {
args = tc.function.arguments;
} else {
try { args = JSON.parse(tc.function.arguments || '{}'); } catch { /* keep empty */ }
}
return {
id: `ollama_${Date.now()}_${i}`,
name: tc.function.name,
arguments: args,
};
});
}
/** Parse native tool_calls from an OpenAI-compatible /v1/chat/completions response. */
private parseOpenAINativeToolCalls(data: {
choices?: Array<{ message?: { tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> } }>;
}): LLMToolCall[] | undefined {
const raw = data.choices?.[0]?.message?.tool_calls;
if (!raw?.length) return undefined;
return raw.map(tc => {
let args: Record<string, unknown> = {};
try { args = JSON.parse(tc.function.arguments || '{}'); } catch { /* keep empty */ }
return { id: tc.id, name: tc.function.name, arguments: args };
});
}
// A versioned base URL (/v1, /v2, /v4, …) means an OpenAI-compatible server
// (/chat/completions, choices[]). Many OpenAI-compatible providers version their
// API at paths other than /v1 (e.g. Zhipu/z.ai exposes /api/paas/v4), so match any
@@ -791,36 +883,83 @@ class LocalAdapter implements LLMProviderAdapter {
const maxTokens = options?.maxTokens || this.config.maxTokens || 4096;
const temperature = options?.temperature ?? this.config.temperature ?? 0.7;
const requestBody = openaiWire
// ── Native function-calling support ──────────────────────────────────
// The text contract is ALWAYS injected (via buildMessages) so the text
// fallback is always available. On top of that we PROBE native tools:
// if the model supports them the response carries message.tool_calls,
// and we parse those instead of the text; the probe result is cached
// per-model so subsequent calls skip the probe overhead.
const tryNative = this.shouldTryNativeTools() && !!options?.tools?.length;
const requestBody: Record<string, unknown> = openaiWire
? { model: this.config.model, messages: wireMessages, max_tokens: maxTokens, temperature, stream: false }
: { model: this.config.model, messages: wireMessages, stream: false, options: { num_predict: maxTokens, temperature } };
if (tryNative && options?.tools) {
requestBody.tools = openaiWire
? this.formatOpenAITools(options.tools)
: this.formatOllamaTools(options.tools);
}
try {
const response = await fetch(url, {
const headers = {
'Content-Type': 'application/json',
// Local OpenAI-compatible servers usually ignore auth, but LM Studio & co.
// expect *some* bearer — send a dummy unless the operator set a real key.
...(openaiWire ? { Authorization: `Bearer ${this.config.apiKey || 'local'}` } : {}),
};
const makeSignal = () => AbortSignal.timeout(this.config.timeout || 120000);
let response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Local OpenAI-compatible servers usually ignore auth, but LM Studio & co.
// expect *some* bearer — send a dummy unless the operator set a real key.
...(openaiWire ? { Authorization: `Bearer ${this.config.apiKey || 'local'}` } : {}),
},
headers,
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(this.config.timeout || 120000),
signal: makeSignal(),
});
// Some local/OpenAI-compatible servers reject the `tools` field instead
// of ignoring it. Retry once without native tools and remember that
// explicit rejection; an optimistic probe must not break text fallback.
if (!response.ok && tryNative && this.config.nativeTools !== true) {
delete requestBody.tools;
this.cacheProbeResult(false);
response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: makeSignal(),
});
}
if (!response.ok) {
throw new Error(`Local LLM error: ${response.status}`);
}
const data = await response.json() as OllamaResponse & {
choices?: Array<{ message?: { content?: string } }>;
choices?: Array<{ message?: { content?: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> } }>;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
};
const content = openaiWire ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || '');
// ── Native tool-call parsing (if we probed) ────────────────────────
let nativeToolCalls: LLMToolCall[] | undefined;
if (tryNative) {
nativeToolCalls = openaiWire
? this.parseOpenAINativeToolCalls(data)
: this.parseOllamaNativeToolCalls(data);
// A returned native call proves support. Its absence does not prove
// lack of support—the model may legitimately answer without a tool.
if (nativeToolCalls) this.cacheProbeResult(true);
}
// Tool-calling over text: if the Arsenal was offered, parse the model's tool
// requests so the ReAct loop EXECUTES them instead of abstaining on turn 0.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
// This is the fallback — only used when native didn't yield tool_calls.
const textToolCalls = !nativeToolCalls && options?.tools?.length
? parseTextToolCalls(content)
: undefined;
const toolCalls = nativeToolCalls ?? textToolCalls;
const usage = openaiWire
? (data.usage
@@ -1578,3 +1717,8 @@ export function createBestAvailableBackbone(): LLMBackbone {
// Re-export types for convenience
export type { LLMMessage, LLMResponse, LLMConfig, LLMToolDefinition, LLMToolCall } from '../types/index.js';
/** @internal Reset the LocalAdapter native-tool-support probe cache (test helper). */
export function __resetLocalAdapterCache(): void {
LocalAdapter.__resetProbeCache();
}