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:
14 files changed
+3019
-63
No files matched your search
+156
-12
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user