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
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ToolError Type Tests
|
||||
*
|
||||
* Tests for ToolErrorCategory enum and ToolError class — structured error
|
||||
* handling for tool validation, execution, and scope enforcement.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolError, ToolErrorCategory, type ToolValidationError } from '../types/index.js';
|
||||
|
||||
describe('ToolErrorCategory', () => {
|
||||
it('should have all expected category values', () => {
|
||||
expect(ToolErrorCategory.ScopeDenied).toBe('scope_denied');
|
||||
expect(ToolErrorCategory.ToolNotFound).toBe('tool_not_found');
|
||||
expect(ToolErrorCategory.Timeout).toBe('timeout');
|
||||
expect(ToolErrorCategory.ExecutionError).toBe('execution_error');
|
||||
expect(ToolErrorCategory.ValidationError).toBe('validation_error');
|
||||
});
|
||||
|
||||
it('should have exactly 5 categories', () => {
|
||||
const values = Object.values(ToolErrorCategory);
|
||||
expect(values).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolError', () => {
|
||||
it('should construct with category and message', () => {
|
||||
const err = new ToolError(ToolErrorCategory.ValidationError, 'Invalid argument');
|
||||
expect(err.category).toBe(ToolErrorCategory.ValidationError);
|
||||
expect(err.message).toBe('Invalid argument');
|
||||
expect(err.name).toBe('ToolError');
|
||||
});
|
||||
|
||||
it('should be an instance of Error', () => {
|
||||
const err = new ToolError(ToolErrorCategory.Timeout, 'Timed out');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it('should accept optional toolName', () => {
|
||||
const err = new ToolError(ToolErrorCategory.ToolNotFound, 'Not found', 'nmap_scan');
|
||||
expect(err.toolName).toBe('nmap_scan');
|
||||
});
|
||||
|
||||
it('should have undefined toolName when not provided', () => {
|
||||
const err = new ToolError(ToolErrorCategory.ExecutionError, 'Failed');
|
||||
expect(err.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve stack trace', () => {
|
||||
const err = new ToolError(ToolErrorCategory.ScopeDenied, 'Denied');
|
||||
expect(err.stack).toBeDefined();
|
||||
expect(err.stack).toContain('ToolError');
|
||||
});
|
||||
|
||||
it('should be throwable and catchable', () => {
|
||||
expect(() => {
|
||||
throw new ToolError(ToolErrorCategory.ValidationError, 'Bad input');
|
||||
}).toThrow(ToolError);
|
||||
});
|
||||
|
||||
it('should allow checking category in catch block', () => {
|
||||
try {
|
||||
throw new ToolError(ToolErrorCategory.Timeout, 'Tool timed out', 'nuclei');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ToolError);
|
||||
if (e instanceof ToolError) {
|
||||
expect(e.category).toBe(ToolErrorCategory.Timeout);
|
||||
expect(e.toolName).toBe('nuclei');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should work with all categories', () => {
|
||||
const categories = [
|
||||
ToolErrorCategory.ScopeDenied,
|
||||
ToolErrorCategory.ToolNotFound,
|
||||
ToolErrorCategory.Timeout,
|
||||
ToolErrorCategory.ExecutionError,
|
||||
ToolErrorCategory.ValidationError,
|
||||
];
|
||||
for (const cat of categories) {
|
||||
const err = new ToolError(cat, `Error for ${cat}`);
|
||||
expect(err.category).toBe(cat);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have readonly category and toolName', () => {
|
||||
const err = new ToolError(ToolErrorCategory.ValidationError, 'msg', 'tool');
|
||||
// TypeScript readonly — runtime still allows assignment but the intent is clear
|
||||
expect(err.category).toBe(ToolErrorCategory.ValidationError);
|
||||
expect(err.toolName).toBe('tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolValidationError', () => {
|
||||
it('should have all required fields', () => {
|
||||
const error: ToolValidationError = {
|
||||
field: 'port',
|
||||
message: 'Port must be a number',
|
||||
received: 'abc',
|
||||
expected: 'number',
|
||||
};
|
||||
expect(error.field).toBe('port');
|
||||
expect(error.message).toBe('Port must be a number');
|
||||
expect(error.received).toBe('abc');
|
||||
expect(error.expected).toBe('number');
|
||||
});
|
||||
|
||||
it('should accept unknown received values', () => {
|
||||
const error: ToolValidationError = {
|
||||
field: 'config',
|
||||
message: 'Invalid type',
|
||||
received: undefined,
|
||||
expected: 'object',
|
||||
};
|
||||
expect(error.received).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept complex received values', () => {
|
||||
const error: ToolValidationError = {
|
||||
field: 'headers',
|
||||
message: 'Array item type mismatch',
|
||||
received: { key: 123 },
|
||||
expected: 'string',
|
||||
};
|
||||
expect(error.received).toEqual({ key: 123 });
|
||||
});
|
||||
|
||||
it('should be usable in arrays for multi-field validation', () => {
|
||||
const errors: ToolValidationError[] = [
|
||||
{ field: 'host', message: 'Required', received: undefined, expected: 'string' },
|
||||
{ field: 'port', message: 'Out of range', received: 99999, expected: '1-65535' },
|
||||
];
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors.map(e => e.field)).toEqual(['host', 'port']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user