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

+138
View File
@@ -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']);
});
});