@artemiskit/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +4 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/types.d.ts +20 -2
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/agent-evaluation/index.d.ts +3 -0
- package/dist/agent-evaluation/index.d.ts.map +1 -0
- package/dist/agent-evaluation/scorer.d.ts +35 -0
- package/dist/agent-evaluation/scorer.d.ts.map +1 -0
- package/dist/agent-evaluation/types.d.ts +37 -0
- package/dist/agent-evaluation/types.d.ts.map +1 -0
- package/dist/artifacts/manifest.d.ts.map +1 -1
- package/dist/artifacts/types.d.ts +42 -0
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/evaluators/index.d.ts +1 -0
- package/dist/evaluators/index.d.ts.map +1 -1
- package/dist/evaluators/json-schema.d.ts +0 -1
- package/dist/evaluators/json-schema.d.ts.map +1 -1
- package/dist/evaluators/llm-grader.d.ts +2 -0
- package/dist/evaluators/llm-grader.d.ts.map +1 -1
- package/dist/evaluators/tool-trace.d.ts +7 -0
- package/dist/evaluators/tool-trace.d.ts.map +1 -0
- package/dist/evaluators/types.d.ts +20 -0
- package/dist/evaluators/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20169 -13138
- package/dist/runner/executor.d.ts.map +1 -1
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/types.d.ts +4 -0
- package/dist/runner/types.d.ts.map +1 -1
- package/dist/scenario/schema.d.ts +721 -63
- package/dist/scenario/schema.d.ts.map +1 -1
- package/dist/storage/supabase.d.ts +1 -1
- package/dist/storage/supabase.d.ts.map +1 -1
- package/dist/storage/types.d.ts +6 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/tools/fixture-executor.d.ts +10 -0
- package/dist/tools/fixture-executor.d.ts.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/schema-validator.d.ts +10 -0
- package/dist/tools/schema-validator.d.ts.map +1 -0
- package/dist/tools/types.d.ts +50 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/types.test.ts +21 -0
- package/src/adapters/types.ts +18 -0
- package/src/agent-evaluation/index.ts +2 -0
- package/src/agent-evaluation/scorer.test.ts +1194 -0
- package/src/agent-evaluation/scorer.ts +640 -0
- package/src/agent-evaluation/types.test.ts +27 -0
- package/src/agent-evaluation/types.ts +43 -0
- package/src/artifacts/manifest.test.ts +66 -19
- package/src/artifacts/manifest.ts +18 -5
- package/src/artifacts/types.ts +49 -0
- package/src/evaluators/index.ts +3 -0
- package/src/evaluators/json-schema.test.ts +130 -0
- package/src/evaluators/json-schema.ts +38 -63
- package/src/evaluators/llm-grader.test.ts +80 -0
- package/src/evaluators/llm-grader.ts +44 -6
- package/src/evaluators/tool-trace.test.ts +46 -0
- package/src/evaluators/tool-trace.ts +50 -0
- package/src/evaluators/types.ts +20 -0
- package/src/index.ts +6 -0
- package/src/runner/executor.test.ts +340 -0
- package/src/runner/executor.ts +289 -20
- package/src/runner/release-validation.test.ts +169 -0
- package/src/runner/runner.ts +5 -1
- package/src/runner/types.ts +4 -0
- package/src/scenario/schema.ts +56 -1
- package/src/storage/supabase.test.ts +65 -1
- package/src/storage/supabase.ts +17 -2
- package/src/storage/types.ts +12 -2
- package/src/tools/fixture-executor.test.ts +88 -0
- package/src/tools/fixture-executor.ts +112 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/schema-validator.test.ts +32 -0
- package/src/tools/schema-validator.ts +56 -0
- package/src/tools/types.ts +80 -0
- package/adapters/openai/dist/index.js +0 -5626
|
@@ -181,6 +181,41 @@ describe('SupabaseStorageAdapter', () => {
|
|
|
181
181
|
const result = await adapter.save(manifest);
|
|
182
182
|
|
|
183
183
|
expect(result).toBe('test-project/test-run-123.json');
|
|
184
|
+
expect(mockUpsert.mock.calls[0][0]).toMatchObject({
|
|
185
|
+
total_attempts: 10,
|
|
186
|
+
valid_evaluations: 10,
|
|
187
|
+
invalid_evaluations: 0,
|
|
188
|
+
outcome_rate_denominator: 10,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should save manifest integrity counts without using invalid measurements as outcomes', async () => {
|
|
193
|
+
const manifest = createMockManifest({
|
|
194
|
+
cases: [],
|
|
195
|
+
metrics: {
|
|
196
|
+
...createMockManifest().metrics,
|
|
197
|
+
total_attempts: 7,
|
|
198
|
+
valid_evaluations: 4,
|
|
199
|
+
invalid_evaluations: 2,
|
|
200
|
+
outcome_rate_denominator: 4,
|
|
201
|
+
passed_cases: 3,
|
|
202
|
+
failed_cases: 1,
|
|
203
|
+
success_rate: 0.75,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
mockStorage.mockReturnValue({ upload: mock(() => Promise.resolve({ error: null })) });
|
|
207
|
+
const mockUpsert = mock(() => Promise.resolve({ error: null }));
|
|
208
|
+
mockFrom.mockReturnValue({ upsert: mockUpsert });
|
|
209
|
+
|
|
210
|
+
await adapter.save(manifest);
|
|
211
|
+
|
|
212
|
+
expect(mockUpsert.mock.calls[0][0]).toMatchObject({
|
|
213
|
+
total_attempts: 7,
|
|
214
|
+
valid_evaluations: 4,
|
|
215
|
+
invalid_evaluations: 2,
|
|
216
|
+
outcome_rate_denominator: 4,
|
|
217
|
+
success_rate: 0.75,
|
|
218
|
+
});
|
|
184
219
|
});
|
|
185
220
|
|
|
186
221
|
it('should throw error on storage upload failure', async () => {
|
|
@@ -577,6 +612,34 @@ describe('SupabaseStorageAdapter', () => {
|
|
|
577
612
|
expect(result).toBe('uuid-123');
|
|
578
613
|
});
|
|
579
614
|
|
|
615
|
+
it('should retain an invalid case status with bounded evidence and attempts', async () => {
|
|
616
|
+
const caseResult = createMockCaseResultRecord({
|
|
617
|
+
status: 'invalid',
|
|
618
|
+
attempts: 2,
|
|
619
|
+
evidence: {
|
|
620
|
+
evaluator: 'llm_grader',
|
|
621
|
+
validation: { status: 'invalid', code: 'grader_failure' },
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
const mockUpsert = mock(() => ({
|
|
625
|
+
select: mock(() => ({
|
|
626
|
+
single: mock(() => Promise.resolve({ data: { id: 'uuid-123' }, error: null })),
|
|
627
|
+
})),
|
|
628
|
+
}));
|
|
629
|
+
mockFrom.mockReturnValue({ upsert: mockUpsert });
|
|
630
|
+
|
|
631
|
+
await adapter.saveCaseResult(caseResult);
|
|
632
|
+
|
|
633
|
+
expect(mockUpsert.mock.calls[0][0]).toMatchObject({
|
|
634
|
+
status: 'invalid',
|
|
635
|
+
attempts: 2,
|
|
636
|
+
evidence: {
|
|
637
|
+
evaluator: 'llm_grader',
|
|
638
|
+
validation: { status: 'invalid', code: 'grader_failure' },
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
580
643
|
it('should throw error on save failure', async () => {
|
|
581
644
|
const caseResult = createMockCaseResultRecord();
|
|
582
645
|
|
|
@@ -980,9 +1043,10 @@ describe('Type Safety', () => {
|
|
|
980
1043
|
});
|
|
981
1044
|
|
|
982
1045
|
it('should have valid CaseResultStatus types', () => {
|
|
983
|
-
const statuses: CaseResultRecord['status'][] = ['passed', 'failed', 'error'];
|
|
1046
|
+
const statuses: CaseResultRecord['status'][] = ['passed', 'failed', 'invalid', 'error'];
|
|
984
1047
|
expect(statuses).toContain('passed');
|
|
985
1048
|
expect(statuses).toContain('failed');
|
|
1049
|
+
expect(statuses).toContain('invalid');
|
|
986
1050
|
expect(statuses).toContain('error');
|
|
987
1051
|
});
|
|
988
1052
|
});
|
package/src/storage/supabase.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { type SupabaseClient, createClient } from '@supabase/supabase-js';
|
|
6
|
-
import type
|
|
6
|
+
import { type CaseResult, type RunManifest, getCaseEvaluationStatus } from '../artifacts/types';
|
|
7
7
|
import type {
|
|
8
8
|
AnalyticsStorageAdapter,
|
|
9
9
|
BaselineMetadata,
|
|
@@ -31,7 +31,8 @@ function mapCaseToRecord(runId: string, caseResult: CaseResult): CaseResultRecor
|
|
|
31
31
|
runId,
|
|
32
32
|
caseId: caseResult.id,
|
|
33
33
|
caseName: caseResult.name,
|
|
34
|
-
status: caseResult
|
|
34
|
+
status: getCaseEvaluationStatus(caseResult),
|
|
35
|
+
attempts: caseResult.attempts ?? 1,
|
|
35
36
|
score: caseResult.score,
|
|
36
37
|
matcherType: caseResult.matcherType,
|
|
37
38
|
reason: caseResult.reason,
|
|
@@ -41,6 +42,7 @@ function mapCaseToRecord(runId: string, caseResult: CaseResult): CaseResultRecor
|
|
|
41
42
|
completionTokens: caseResult.tokens.completion,
|
|
42
43
|
totalTokens: caseResult.tokens.total,
|
|
43
44
|
error: caseResult.error,
|
|
45
|
+
evidence: caseResult.evidence,
|
|
44
46
|
tags: caseResult.tags,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
@@ -84,6 +86,11 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
84
86
|
total_cases: manifest.metrics.total_cases,
|
|
85
87
|
passed_cases: manifest.metrics.passed_cases,
|
|
86
88
|
failed_cases: manifest.metrics.failed_cases,
|
|
89
|
+
total_attempts: manifest.metrics.total_attempts ?? manifest.metrics.total_cases,
|
|
90
|
+
valid_evaluations: manifest.metrics.valid_evaluations ?? manifest.metrics.total_cases,
|
|
91
|
+
invalid_evaluations: manifest.metrics.invalid_evaluations ?? 0,
|
|
92
|
+
outcome_rate_denominator:
|
|
93
|
+
manifest.metrics.outcome_rate_denominator ?? manifest.metrics.total_cases,
|
|
87
94
|
median_latency_ms: manifest.metrics.median_latency_ms,
|
|
88
95
|
p95_latency_ms: manifest.metrics.p95_latency_ms,
|
|
89
96
|
total_tokens: manifest.metrics.total_tokens,
|
|
@@ -405,6 +412,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
405
412
|
case_id: result.caseId,
|
|
406
413
|
case_name: result.caseName,
|
|
407
414
|
status: result.status,
|
|
415
|
+
attempts: result.attempts ?? 1,
|
|
408
416
|
score: result.score,
|
|
409
417
|
matcher_type: result.matcherType,
|
|
410
418
|
reason: result.reason,
|
|
@@ -414,6 +422,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
414
422
|
completion_tokens: result.completionTokens,
|
|
415
423
|
total_tokens: result.totalTokens,
|
|
416
424
|
error: result.error,
|
|
425
|
+
evidence: result.evidence,
|
|
417
426
|
tags: result.tags || [],
|
|
418
427
|
};
|
|
419
428
|
|
|
@@ -440,6 +449,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
440
449
|
case_id: r.caseId,
|
|
441
450
|
case_name: r.caseName,
|
|
442
451
|
status: r.status,
|
|
452
|
+
attempts: r.attempts ?? 1,
|
|
443
453
|
score: r.score,
|
|
444
454
|
matcher_type: r.matcherType,
|
|
445
455
|
reason: r.reason,
|
|
@@ -449,6 +459,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
449
459
|
completion_tokens: r.completionTokens,
|
|
450
460
|
total_tokens: r.totalTokens,
|
|
451
461
|
error: r.error,
|
|
462
|
+
evidence: r.evidence,
|
|
452
463
|
tags: r.tags || [],
|
|
453
464
|
}));
|
|
454
465
|
|
|
@@ -481,6 +492,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
481
492
|
caseId: r.case_id,
|
|
482
493
|
caseName: r.case_name,
|
|
483
494
|
status: r.status,
|
|
495
|
+
attempts: r.attempts,
|
|
484
496
|
score: r.score,
|
|
485
497
|
matcherType: r.matcher_type,
|
|
486
498
|
reason: r.reason,
|
|
@@ -490,6 +502,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
490
502
|
completionTokens: r.completion_tokens,
|
|
491
503
|
totalTokens: r.total_tokens,
|
|
492
504
|
error: r.error,
|
|
505
|
+
evidence: r.evidence,
|
|
493
506
|
tags: r.tags,
|
|
494
507
|
createdAt: r.created_at,
|
|
495
508
|
}));
|
|
@@ -531,6 +544,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
531
544
|
caseId: r.case_id,
|
|
532
545
|
caseName: r.case_name,
|
|
533
546
|
status: r.status,
|
|
547
|
+
attempts: r.attempts,
|
|
534
548
|
score: r.score,
|
|
535
549
|
matcherType: r.matcher_type,
|
|
536
550
|
reason: r.reason,
|
|
@@ -540,6 +554,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
|
|
|
540
554
|
completionTokens: r.completion_tokens,
|
|
541
555
|
totalTokens: r.total_tokens,
|
|
542
556
|
error: r.error,
|
|
557
|
+
evidence: r.evidence,
|
|
543
558
|
tags: r.tags,
|
|
544
559
|
createdAt: r.created_at,
|
|
545
560
|
}));
|
package/src/storage/types.ts
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
* Storage types and interfaces
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
AnyManifest,
|
|
7
|
+
CaseEvaluationEvidence,
|
|
8
|
+
RedTeamManifest,
|
|
9
|
+
RunManifest,
|
|
10
|
+
StressManifest,
|
|
11
|
+
} from '../artifacts/types';
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Run listing item
|
|
@@ -181,7 +187,7 @@ export interface BaselineStorageAdapter extends StorageAdapter {
|
|
|
181
187
|
/**
|
|
182
188
|
* Status of an individual case result
|
|
183
189
|
*/
|
|
184
|
-
export type CaseResultStatus = 'passed' | 'failed' | 'error';
|
|
190
|
+
export type CaseResultStatus = 'passed' | 'failed' | 'invalid' | 'error';
|
|
185
191
|
|
|
186
192
|
/**
|
|
187
193
|
* Individual case result record for storage
|
|
@@ -197,6 +203,8 @@ export interface CaseResultRecord {
|
|
|
197
203
|
caseName?: string;
|
|
198
204
|
/** Result status */
|
|
199
205
|
status: CaseResultStatus;
|
|
206
|
+
/** Number of execution attempts represented by this terminal result. */
|
|
207
|
+
attempts?: number;
|
|
200
208
|
/** Score from 0.0 to 1.0 */
|
|
201
209
|
score: number;
|
|
202
210
|
/** Type of matcher used */
|
|
@@ -215,6 +223,8 @@ export interface CaseResultRecord {
|
|
|
215
223
|
totalTokens: number;
|
|
216
224
|
/** Error message if status is 'error' */
|
|
217
225
|
error?: string;
|
|
226
|
+
/** Sanitized evaluator evidence retained for review. */
|
|
227
|
+
evidence?: CaseEvaluationEvidence;
|
|
218
228
|
/** Tags for categorization */
|
|
219
229
|
tags?: string[];
|
|
220
230
|
/** ISO timestamp when created */
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { FixtureToolExecutor } from './fixture-executor';
|
|
3
|
+
|
|
4
|
+
const tools = [
|
|
5
|
+
{
|
|
6
|
+
type: 'function' as const,
|
|
7
|
+
function: {
|
|
8
|
+
name: 'get_weather',
|
|
9
|
+
parameters: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
required: ['city'],
|
|
13
|
+
properties: { city: { type: 'string' } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const call = (arguments_: string, name = 'get_weather') => ({
|
|
20
|
+
id: 'call-1',
|
|
21
|
+
type: 'function' as const,
|
|
22
|
+
function: { name, arguments: arguments_ },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('FixtureToolExecutor', () => {
|
|
26
|
+
it('returns a deterministic matching fixture result', async () => {
|
|
27
|
+
const executor = new FixtureToolExecutor({
|
|
28
|
+
tools,
|
|
29
|
+
fixtures: { get_weather: [{ when: { city: 'Lagos' }, result: { temperature_c: 28 } }] },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(executor.execute(call('{"city":"Lagos"}'))).resolves.toEqual({
|
|
33
|
+
status: 'success',
|
|
34
|
+
result: { temperature_c: 28 },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects undeclared tools, invalid JSON, and unmatched fixtures', async () => {
|
|
39
|
+
const executor = new FixtureToolExecutor({ tools, fixtures: { get_weather: [] } });
|
|
40
|
+
|
|
41
|
+
await expect(executor.execute(call('{}', 'delete_weather'))).resolves.toMatchObject({
|
|
42
|
+
status: 'error',
|
|
43
|
+
error: { code: 'TOOL_UNKNOWN' },
|
|
44
|
+
});
|
|
45
|
+
await expect(executor.execute(call('{city:Lagos}'))).resolves.toMatchObject({
|
|
46
|
+
status: 'error',
|
|
47
|
+
error: { code: 'TOOL_ARGUMENTS_INVALID' },
|
|
48
|
+
});
|
|
49
|
+
await expect(executor.execute(call('{"city":"Lagos"}'))).resolves.toMatchObject({
|
|
50
|
+
status: 'error',
|
|
51
|
+
error: { code: 'TOOL_FIXTURE_NOT_FOUND' },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns controlled fixture errors and result-size failures', async () => {
|
|
56
|
+
const errorExecutor = new FixtureToolExecutor({
|
|
57
|
+
tools,
|
|
58
|
+
fixtures: { get_weather: [{ error: 'Weather service is unavailable.' }] },
|
|
59
|
+
});
|
|
60
|
+
const limitedExecutor = new FixtureToolExecutor({
|
|
61
|
+
tools,
|
|
62
|
+
fixtures: { get_weather: [{ result: { forecast: 'x'.repeat(32) } }] },
|
|
63
|
+
maxToolResultBytes: 16,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(errorExecutor.execute(call('{"city":"Lagos"}'))).resolves.toMatchObject({
|
|
67
|
+
status: 'error',
|
|
68
|
+
error: { code: 'TOOL_EXECUTION_FAILED' },
|
|
69
|
+
});
|
|
70
|
+
await expect(limitedExecutor.execute(call('{"city":"Lagos"}'))).resolves.toMatchObject({
|
|
71
|
+
status: 'error',
|
|
72
|
+
error: { code: 'TOOL_RESULT_TOO_LARGE' },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects unsafe fixture configuration and fixtures for undeclared tools', () => {
|
|
77
|
+
expect(
|
|
78
|
+
() =>
|
|
79
|
+
new FixtureToolExecutor({
|
|
80
|
+
tools,
|
|
81
|
+
fixtures: { get_weather: [{ result: { command: 'curl example.invalid' } }] },
|
|
82
|
+
})
|
|
83
|
+
).toThrow('TOOL_FIXTURE_UNSAFE');
|
|
84
|
+
expect(() => new FixtureToolExecutor({ tools, fixtures: { no_such_tool: [] } })).toThrow(
|
|
85
|
+
'TOOL_UNKNOWN'
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ToolCall, ToolDefinition } from '../adapters/types';
|
|
2
|
+
import { validateToolArguments } from './schema-validator';
|
|
3
|
+
import type {
|
|
4
|
+
FixtureExecutorOptions,
|
|
5
|
+
ToolExecutionContext,
|
|
6
|
+
ToolExecutionResult,
|
|
7
|
+
ToolFixture,
|
|
8
|
+
ToolFixtures,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
const DISALLOWED_FIXTURE_FIELDS = new Set(['command', 'url', 'path', 'code', 'environment', 'env']);
|
|
12
|
+
|
|
13
|
+
export class FixtureToolExecutor {
|
|
14
|
+
private readonly tools = new Map<string, ToolDefinition>();
|
|
15
|
+
private readonly fixtures: ToolFixtures;
|
|
16
|
+
private readonly maxToolResultBytes: number;
|
|
17
|
+
|
|
18
|
+
constructor({ tools, fixtures, maxToolResultBytes = 32_768 }: FixtureExecutorOptions) {
|
|
19
|
+
for (const tool of tools) this.tools.set(tool.function.name, tool);
|
|
20
|
+
for (const [name, entries] of Object.entries(fixtures)) {
|
|
21
|
+
if (!this.tools.has(name)) {
|
|
22
|
+
throw new Error(`TOOL_UNKNOWN: fixture declared for undeclared tool '${name}'`);
|
|
23
|
+
}
|
|
24
|
+
entries.forEach(assertSafeFixture);
|
|
25
|
+
}
|
|
26
|
+
this.fixtures = fixtures;
|
|
27
|
+
this.maxToolResultBytes = maxToolResultBytes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async execute(call: ToolCall, _context?: ToolExecutionContext): Promise<ToolExecutionResult> {
|
|
31
|
+
const tool = this.tools.get(call.function.name);
|
|
32
|
+
if (!tool) {
|
|
33
|
+
return error('TOOL_UNKNOWN', `Tool '${call.function.name}' is not declared.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const validated = validateToolArguments(call.function.arguments, tool.function.parameters);
|
|
37
|
+
if (!validated.valid) return { status: 'error', error: validated.error };
|
|
38
|
+
|
|
39
|
+
const fixture = this.fixtures[call.function.name]?.find((candidate) =>
|
|
40
|
+
matches(candidate.when, validated.arguments ?? {})
|
|
41
|
+
);
|
|
42
|
+
if (!fixture) {
|
|
43
|
+
return error('TOOL_FIXTURE_NOT_FOUND', `No fixture matched tool '${call.function.name}'.`);
|
|
44
|
+
}
|
|
45
|
+
if (fixture.error) return error('TOOL_EXECUTION_FAILED', fixture.error);
|
|
46
|
+
|
|
47
|
+
const serialized = JSON.stringify(fixture.result ?? {});
|
|
48
|
+
if (
|
|
49
|
+
serialized === undefined ||
|
|
50
|
+
new TextEncoder().encode(serialized).byteLength > this.maxToolResultBytes
|
|
51
|
+
) {
|
|
52
|
+
return error(
|
|
53
|
+
'TOOL_RESULT_TOO_LARGE',
|
|
54
|
+
`Tool '${call.function.name}' result exceeds the configured limit.`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return { status: 'success', result: fixture.result ?? {} };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function error(
|
|
62
|
+
code: NonNullable<ToolExecutionResult['error']>['code'],
|
|
63
|
+
message: string
|
|
64
|
+
): ToolExecutionResult {
|
|
65
|
+
return { status: 'error', error: { code, message } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function matches(
|
|
69
|
+
when: Record<string, unknown> | undefined,
|
|
70
|
+
args: Record<string, unknown>
|
|
71
|
+
): boolean {
|
|
72
|
+
return !when || Object.entries(when).every(([key, value]) => deepEqual(args[key], value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function deepEqual(left: unknown, right: unknown): boolean {
|
|
76
|
+
if (Object.is(left, right)) return true;
|
|
77
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false;
|
|
78
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
79
|
+
return (
|
|
80
|
+
Array.isArray(left) &&
|
|
81
|
+
Array.isArray(right) &&
|
|
82
|
+
left.length === right.length &&
|
|
83
|
+
left.every((value, index) => deepEqual(value, right[index]))
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const leftRecord = left as Record<string, unknown>;
|
|
87
|
+
const rightRecord = right as Record<string, unknown>;
|
|
88
|
+
const keys = Object.keys(rightRecord);
|
|
89
|
+
return (
|
|
90
|
+
keys.length === Object.keys(leftRecord).length &&
|
|
91
|
+
keys.every((key) => deepEqual(leftRecord[key], rightRecord[key]))
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertSafeFixture(fixture: ToolFixture): void {
|
|
96
|
+
assertSafeValue(fixture.when);
|
|
97
|
+
assertSafeValue(fixture.result);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertSafeValue(value: unknown): void {
|
|
101
|
+
if (!value || typeof value !== 'object') return;
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
value.forEach(assertSafeValue);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
107
|
+
if (DISALLOWED_FIXTURE_FIELDS.has(key.toLowerCase())) {
|
|
108
|
+
throw new Error(`TOOL_FIXTURE_UNSAFE: '${key}' is not permitted in fixture data.`);
|
|
109
|
+
}
|
|
110
|
+
assertSafeValue(child);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { validateToolArguments } from './schema-validator';
|
|
3
|
+
|
|
4
|
+
const weatherSchema = {
|
|
5
|
+
type: 'object',
|
|
6
|
+
additionalProperties: false,
|
|
7
|
+
required: ['city'],
|
|
8
|
+
properties: { city: { type: 'string', minLength: 1 } },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('validateToolArguments', () => {
|
|
12
|
+
it('returns parsed arguments that satisfy the JSON schema', () => {
|
|
13
|
+
expect(validateToolArguments('{"city":"Lagos"}', weatherSchema)).toEqual({
|
|
14
|
+
valid: true,
|
|
15
|
+
arguments: { city: 'Lagos' },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('rejects malformed JSON without echoing the arguments', () => {
|
|
20
|
+
expect(validateToolArguments('{city:Lagos}', weatherSchema)).toEqual({
|
|
21
|
+
valid: false,
|
|
22
|
+
error: { code: 'TOOL_ARGUMENTS_INVALID', message: 'Tool arguments must be valid JSON.' },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects arguments that fail the declared schema', () => {
|
|
27
|
+
const result = validateToolArguments('{"city":42}', weatherSchema);
|
|
28
|
+
|
|
29
|
+
expect(result.valid).toBe(false);
|
|
30
|
+
expect(result.error?.code).toBe('TOOL_ARGUMENTS_SCHEMA_INVALID');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Ajv, { type ValidateFunction } from 'ajv';
|
|
2
|
+
|
|
3
|
+
export interface ToolArgumentValidation {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
arguments?: Record<string, unknown>;
|
|
6
|
+
error?: { code: 'TOOL_ARGUMENTS_INVALID' | 'TOOL_ARGUMENTS_SCHEMA_INVALID'; message: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
10
|
+
const validators = new WeakMap<object, ValidateFunction>();
|
|
11
|
+
|
|
12
|
+
function getValidator(schema: Record<string, unknown>): ValidateFunction {
|
|
13
|
+
const cached = validators.get(schema);
|
|
14
|
+
if (cached) return cached;
|
|
15
|
+
const validator = ajv.compile(schema);
|
|
16
|
+
validators.set(schema, validator);
|
|
17
|
+
return validator;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateToolArguments(
|
|
21
|
+
rawArguments: string,
|
|
22
|
+
schema: Record<string, unknown>
|
|
23
|
+
): ToolArgumentValidation {
|
|
24
|
+
let args: unknown;
|
|
25
|
+
try {
|
|
26
|
+
args = JSON.parse(rawArguments);
|
|
27
|
+
} catch {
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
error: { code: 'TOOL_ARGUMENTS_INVALID', message: 'Tool arguments must be valid JSON.' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
error: { code: 'TOOL_ARGUMENTS_INVALID', message: 'Tool arguments must be a JSON object.' },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const validator = getValidator(schema);
|
|
42
|
+
if (!validator(args)) {
|
|
43
|
+
const details = (validator.errors ?? [])
|
|
44
|
+
.map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`)
|
|
45
|
+
.join('; ');
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: {
|
|
49
|
+
code: 'TOOL_ARGUMENTS_SCHEMA_INVALID',
|
|
50
|
+
message: `Tool arguments do not match the schema: ${details}`,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { valid: true, arguments: args as Record<string, unknown> };
|
|
56
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ToolCall, ToolDefinition } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
export type ToolLoopTerminationReason =
|
|
4
|
+
| 'completed'
|
|
5
|
+
| 'max_steps'
|
|
6
|
+
| 'timeout'
|
|
7
|
+
| 'duplicate_call'
|
|
8
|
+
| 'invalid_arguments'
|
|
9
|
+
| 'unknown_tool'
|
|
10
|
+
| 'tool_error';
|
|
11
|
+
|
|
12
|
+
export interface ToolLoopPolicy {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
maxSteps: number;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
maxToolResultBytes: number;
|
|
17
|
+
rejectDuplicateCalls: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_TOOL_LOOP_POLICY: ToolLoopPolicy = {
|
|
21
|
+
enabled: false,
|
|
22
|
+
maxSteps: 5,
|
|
23
|
+
timeoutMs: 60_000,
|
|
24
|
+
maxToolResultBytes: 32_768,
|
|
25
|
+
rejectDuplicateCalls: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface ToolExecutionContext {
|
|
29
|
+
caseId: string;
|
|
30
|
+
step: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToolExecutionError {
|
|
34
|
+
code:
|
|
35
|
+
| 'TOOL_ARGUMENTS_INVALID'
|
|
36
|
+
| 'TOOL_ARGUMENTS_SCHEMA_INVALID'
|
|
37
|
+
| 'TOOL_UNKNOWN'
|
|
38
|
+
| 'TOOL_FIXTURE_NOT_FOUND'
|
|
39
|
+
| 'TOOL_EXECUTION_FAILED'
|
|
40
|
+
| 'TOOL_RESULT_TOO_LARGE';
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ToolExecutionResult {
|
|
45
|
+
status: 'success' | 'error';
|
|
46
|
+
result?: unknown;
|
|
47
|
+
error?: ToolExecutionError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ToolExecutor {
|
|
51
|
+
execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ToolTraceEntry {
|
|
55
|
+
step: number;
|
|
56
|
+
toolCall: ToolCall;
|
|
57
|
+
result?: unknown;
|
|
58
|
+
error?: ToolExecutionError;
|
|
59
|
+
latencyMs: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ToolLoopSummary {
|
|
63
|
+
status: 'completed' | 'error';
|
|
64
|
+
steps: number;
|
|
65
|
+
terminationReason: ToolLoopTerminationReason;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ToolFixture {
|
|
69
|
+
when?: Record<string, unknown>;
|
|
70
|
+
result?: unknown;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ToolFixtures = Record<string, ToolFixture[]>;
|
|
75
|
+
|
|
76
|
+
export interface FixtureExecutorOptions {
|
|
77
|
+
tools: ToolDefinition[];
|
|
78
|
+
fixtures: ToolFixtures;
|
|
79
|
+
maxToolResultBytes?: number;
|
|
80
|
+
}
|