@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +4 -0
  3. package/dist/adapters/registry.d.ts.map +1 -1
  4. package/dist/adapters/types.d.ts +20 -2
  5. package/dist/adapters/types.d.ts.map +1 -1
  6. package/dist/agent-evaluation/index.d.ts +3 -0
  7. package/dist/agent-evaluation/index.d.ts.map +1 -0
  8. package/dist/agent-evaluation/scorer.d.ts +35 -0
  9. package/dist/agent-evaluation/scorer.d.ts.map +1 -0
  10. package/dist/agent-evaluation/types.d.ts +37 -0
  11. package/dist/agent-evaluation/types.d.ts.map +1 -0
  12. package/dist/artifacts/manifest.d.ts.map +1 -1
  13. package/dist/artifacts/types.d.ts +42 -0
  14. package/dist/artifacts/types.d.ts.map +1 -1
  15. package/dist/evaluators/index.d.ts +1 -0
  16. package/dist/evaluators/index.d.ts.map +1 -1
  17. package/dist/evaluators/json-schema.d.ts +0 -1
  18. package/dist/evaluators/json-schema.d.ts.map +1 -1
  19. package/dist/evaluators/llm-grader.d.ts +2 -0
  20. package/dist/evaluators/llm-grader.d.ts.map +1 -1
  21. package/dist/evaluators/tool-trace.d.ts +7 -0
  22. package/dist/evaluators/tool-trace.d.ts.map +1 -0
  23. package/dist/evaluators/types.d.ts +20 -0
  24. package/dist/evaluators/types.d.ts.map +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +20169 -13138
  28. package/dist/runner/executor.d.ts.map +1 -1
  29. package/dist/runner/runner.d.ts.map +1 -1
  30. package/dist/runner/types.d.ts +4 -0
  31. package/dist/runner/types.d.ts.map +1 -1
  32. package/dist/scenario/schema.d.ts +721 -63
  33. package/dist/scenario/schema.d.ts.map +1 -1
  34. package/dist/storage/supabase.d.ts +1 -1
  35. package/dist/storage/supabase.d.ts.map +1 -1
  36. package/dist/storage/types.d.ts +6 -2
  37. package/dist/storage/types.d.ts.map +1 -1
  38. package/dist/tools/fixture-executor.d.ts +10 -0
  39. package/dist/tools/fixture-executor.d.ts.map +1 -0
  40. package/dist/tools/index.d.ts +4 -0
  41. package/dist/tools/index.d.ts.map +1 -0
  42. package/dist/tools/schema-validator.d.ts +10 -0
  43. package/dist/tools/schema-validator.d.ts.map +1 -0
  44. package/dist/tools/types.d.ts +50 -0
  45. package/dist/tools/types.d.ts.map +1 -0
  46. package/package.json +2 -1
  47. package/src/adapters/registry.ts +7 -0
  48. package/src/adapters/types.test.ts +21 -0
  49. package/src/adapters/types.ts +18 -0
  50. package/src/agent-evaluation/index.ts +2 -0
  51. package/src/agent-evaluation/scorer.test.ts +1194 -0
  52. package/src/agent-evaluation/scorer.ts +640 -0
  53. package/src/agent-evaluation/types.test.ts +27 -0
  54. package/src/agent-evaluation/types.ts +43 -0
  55. package/src/artifacts/manifest.test.ts +66 -19
  56. package/src/artifacts/manifest.ts +18 -5
  57. package/src/artifacts/types.ts +49 -0
  58. package/src/evaluators/index.ts +3 -0
  59. package/src/evaluators/json-schema.test.ts +130 -0
  60. package/src/evaluators/json-schema.ts +38 -63
  61. package/src/evaluators/llm-grader.test.ts +80 -0
  62. package/src/evaluators/llm-grader.ts +44 -6
  63. package/src/evaluators/tool-trace.test.ts +46 -0
  64. package/src/evaluators/tool-trace.ts +50 -0
  65. package/src/evaluators/types.ts +20 -0
  66. package/src/index.ts +6 -0
  67. package/src/runner/executor.test.ts +340 -0
  68. package/src/runner/executor.ts +289 -20
  69. package/src/runner/release-validation.test.ts +169 -0
  70. package/src/runner/runner.ts +5 -1
  71. package/src/runner/types.ts +4 -0
  72. package/src/scenario/schema.ts +56 -1
  73. package/src/storage/supabase.test.ts +65 -1
  74. package/src/storage/supabase.ts +17 -2
  75. package/src/storage/types.ts +12 -2
  76. package/src/tools/fixture-executor.test.ts +88 -0
  77. package/src/tools/fixture-executor.ts +112 -0
  78. package/src/tools/index.ts +3 -0
  79. package/src/tools/schema-validator.test.ts +32 -0
  80. package/src/tools/schema-validator.ts +56 -0
  81. package/src/tools/types.ts +80 -0
  82. 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
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { type SupabaseClient, createClient } from '@supabase/supabase-js';
6
- import type { CaseResult, RunManifest } from '../artifacts/types';
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.error ? 'error' : caseResult.ok ? 'passed' : 'failed',
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
  }));
@@ -2,7 +2,13 @@
2
2
  * Storage types and interfaces
3
3
  */
4
4
 
5
- import type { AnyManifest, RedTeamManifest, RunManifest, StressManifest } from '../artifacts/types';
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,3 @@
1
+ export * from './fixture-executor';
2
+ export * from './schema-validator';
3
+ export * from './types';
@@ -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
+ }