@artemiskit/core 0.3.0 → 0.4.1

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 (86) hide show
  1. package/CHANGELOG.md +17 -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 +52 -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 +20281 -13144
  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/local.d.ts +1 -1
  35. package/dist/storage/local.d.ts.map +1 -1
  36. package/dist/storage/supabase.d.ts +1 -1
  37. package/dist/storage/supabase.d.ts.map +1 -1
  38. package/dist/storage/types.d.ts +6 -2
  39. package/dist/storage/types.d.ts.map +1 -1
  40. package/dist/tools/fixture-executor.d.ts +10 -0
  41. package/dist/tools/fixture-executor.d.ts.map +1 -0
  42. package/dist/tools/index.d.ts +4 -0
  43. package/dist/tools/index.d.ts.map +1 -0
  44. package/dist/tools/schema-validator.d.ts +10 -0
  45. package/dist/tools/schema-validator.d.ts.map +1 -0
  46. package/dist/tools/types.d.ts +50 -0
  47. package/dist/tools/types.d.ts.map +1 -0
  48. package/package.json +2 -1
  49. package/src/adapters/registry.ts +7 -0
  50. package/src/adapters/types.test.ts +21 -0
  51. package/src/adapters/types.ts +18 -0
  52. package/src/agent-evaluation/index.ts +2 -0
  53. package/src/agent-evaluation/scorer.test.ts +1194 -0
  54. package/src/agent-evaluation/scorer.ts +640 -0
  55. package/src/agent-evaluation/types.test.ts +27 -0
  56. package/src/agent-evaluation/types.ts +43 -0
  57. package/src/artifacts/manifest.test.ts +90 -19
  58. package/src/artifacts/manifest.ts +18 -5
  59. package/src/artifacts/types.ts +133 -0
  60. package/src/evaluators/index.ts +3 -0
  61. package/src/evaluators/json-schema.test.ts +130 -0
  62. package/src/evaluators/json-schema.ts +38 -63
  63. package/src/evaluators/llm-grader.test.ts +80 -0
  64. package/src/evaluators/llm-grader.ts +44 -6
  65. package/src/evaluators/tool-trace.test.ts +46 -0
  66. package/src/evaluators/tool-trace.ts +50 -0
  67. package/src/evaluators/types.ts +20 -0
  68. package/src/index.ts +6 -0
  69. package/src/runner/executor.test.ts +374 -0
  70. package/src/runner/executor.ts +349 -22
  71. package/src/runner/release-validation.test.ts +169 -0
  72. package/src/runner/runner.ts +7 -1
  73. package/src/runner/types.ts +4 -0
  74. package/src/scenario/schema.ts +56 -1
  75. package/src/storage/local.test.ts +24 -0
  76. package/src/storage/local.ts +13 -2
  77. package/src/storage/supabase.test.ts +111 -1
  78. package/src/storage/supabase.ts +26 -3
  79. package/src/storage/types.ts +12 -2
  80. package/src/tools/fixture-executor.test.ts +88 -0
  81. package/src/tools/fixture-executor.ts +112 -0
  82. package/src/tools/index.ts +3 -0
  83. package/src/tools/schema-validator.test.ts +32 -0
  84. package/src/tools/schema-validator.ts +56 -0
  85. package/src/tools/types.ts +80 -0
  86. package/adapters/openai/dist/index.js +0 -5626
@@ -20,6 +20,7 @@ export const ProviderSchema = z.enum([
20
20
  'ollama',
21
21
  'langchain',
22
22
  'deepagents',
23
+ 'ling',
23
24
  'custom',
24
25
  ]);
25
26
 
@@ -60,6 +61,9 @@ export const ProviderConfigSchema = z
60
61
  // DeepAgents specific
61
62
  captureTraces: z.boolean().optional(),
62
63
  captureMessages: z.boolean().optional(),
64
+ thinking: z.object({ type: z.enum(['enabled', 'disabled']) }).optional(),
65
+ enableSearch: z.boolean().optional(),
66
+ searchOptions: z.record(z.unknown()).optional(),
63
67
  })
64
68
  .optional();
65
69
 
@@ -91,6 +95,8 @@ const BaseExpectedSchema = z.discriminatedUnion('type', [
91
95
  model: z.string().optional(),
92
96
  provider: ProviderSchema.optional(),
93
97
  threshold: z.number().min(0).max(1).default(0.7),
98
+ /** Require exact, validated JSON from the judge for assurance assessments. */
99
+ strict: z.boolean().optional().default(false),
94
100
  }),
95
101
 
96
102
  z.object({
@@ -116,6 +122,14 @@ const BaseExpectedSchema = z.discriminatedUnion('type', [
116
122
  config: z.record(z.unknown()).optional(),
117
123
  }),
118
124
 
125
+ z.object({
126
+ type: z.literal('tool_trace'),
127
+ requiredTools: z.array(z.string()).optional(),
128
+ forbiddenTools: z.array(z.string()).optional(),
129
+ ordered: z.boolean().optional().default(false),
130
+ maxCalls: z.number().int().min(0).optional(),
131
+ }),
132
+
119
133
  z.object({
120
134
  type: z.literal('similarity'),
121
135
  value: z.string(),
@@ -155,10 +169,39 @@ export const ExpectedSchema = z.union([BaseExpectedSchema, CombinedExpectedSchem
155
169
  * Chat message schema
156
170
  */
157
171
  export const ChatMessageSchema = z.object({
158
- role: z.enum(['system', 'user', 'assistant']),
172
+ role: z.enum(['system', 'user', 'assistant', 'tool']),
159
173
  content: z.string(),
174
+ name: z.string().optional(),
175
+ toolCallId: z.string().optional(),
176
+ tool_calls: z
177
+ .array(
178
+ z.object({
179
+ id: z.string(),
180
+ type: z.literal('function'),
181
+ function: z.object({ name: z.string(), arguments: z.string() }),
182
+ })
183
+ )
184
+ .optional(),
160
185
  });
161
186
 
187
+ const ToolSchema = z
188
+ .object({
189
+ type: z.literal('function'),
190
+ function: z.object({
191
+ name: z.string(),
192
+ description: z.string().optional(),
193
+ parameters: z.record(z.unknown()),
194
+ }),
195
+ })
196
+ .strict();
197
+ const ToolFixtureSchema = z
198
+ .object({
199
+ when: z.record(z.unknown()).optional(),
200
+ result: z.unknown().optional(),
201
+ error: z.string().optional(),
202
+ })
203
+ .strict();
204
+
162
205
  /**
163
206
  * Variables schema - key-value pairs for template substitution
164
207
  */
@@ -211,6 +254,18 @@ export const ScenarioSchema = z.object({
211
254
  .object({
212
255
  systemPrompt: z.string().optional(),
213
256
  functions: z.array(z.unknown()).optional(),
257
+ tools: z.array(ToolSchema).optional(),
258
+ fixtures: z.record(z.array(ToolFixtureSchema)).optional(),
259
+ toolLoop: z
260
+ .object({
261
+ enabled: z.boolean().default(false),
262
+ maxSteps: z.number().int().min(1).max(10).default(5),
263
+ timeoutMs: z.number().int().min(1).max(300_000).default(60_000),
264
+ maxToolResultBytes: z.number().int().min(1).max(1_048_576).default(32_768),
265
+ rejectDuplicateCalls: z.boolean().default(true),
266
+ })
267
+ .strict()
268
+ .optional(),
214
269
  })
215
270
  .optional(),
216
271
  cases: z.array(TestCaseSchema).min(1),
@@ -77,6 +77,30 @@ describe('LocalStorageAdapter', () => {
77
77
  expect(loaded.metrics.success_rate).toBe(0.8);
78
78
  });
79
79
 
80
+ test('rejects malformed evaluator evidence before writing a standard run', async () => {
81
+ const malformed = {
82
+ ...mockManifest,
83
+ run_id: 'invalid-evidence',
84
+ cases: [
85
+ {
86
+ id: 'case-1',
87
+ ok: false,
88
+ score: 0,
89
+ matcherType: 'custom',
90
+ latencyMs: 1,
91
+ tokens: { prompt: 0, completion: 0, total: 0 },
92
+ prompt: 'prompt',
93
+ response: 'response',
94
+ expected: {},
95
+ tags: [],
96
+ evidence: { evaluator: 'custom', threshold: 2 },
97
+ },
98
+ ],
99
+ } as RunManifest;
100
+
101
+ await expect(storage.save(malformed)).rejects.toThrow('invalid evidence threshold');
102
+ });
103
+
80
104
  test('throws error for non-existent run', async () => {
81
105
  await expect(storage.load('non-existent-run')).rejects.toThrow('Run not found');
82
106
  });
@@ -4,7 +4,14 @@
4
4
 
5
5
  import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
6
6
  import { join, resolve } from 'node:path';
7
- import type { AnyManifest, RedTeamManifest, RunManifest, StressManifest } from '../artifacts/types';
7
+ import {
8
+ type AnyManifest,
9
+ type RedTeamManifest,
10
+ type RunManifest,
11
+ type StressManifest,
12
+ assertRunManifestIntegrity,
13
+ isRunManifest,
14
+ } from '../artifacts/types';
8
15
  import type {
9
16
  BaselineMetadata,
10
17
  BaselineStorageAdapter,
@@ -78,6 +85,9 @@ export class LocalStorageAdapter implements BaselineStorageAdapter {
78
85
  }
79
86
 
80
87
  async save(manifest: AnyManifest): Promise<string> {
88
+ if (isRunManifest(manifest)) {
89
+ assertRunManifestIntegrity(manifest);
90
+ }
81
91
  const dir = join(this.basePath, manifest.project);
82
92
  await mkdir(dir, { recursive: true });
83
93
 
@@ -106,7 +116,8 @@ export class LocalStorageAdapter implements BaselineStorageAdapter {
106
116
  if (getManifestType(manifest) !== 'run') {
107
117
  throw new Error(`Run ${runId} is not a standard run manifest`);
108
118
  }
109
- return manifest as RunManifest;
119
+ assertRunManifestIntegrity(manifest);
120
+ return manifest;
110
121
  }
111
122
 
112
123
  async loadRedTeam(runId: string): Promise<RedTeamManifest> {
@@ -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 () => {
@@ -206,6 +241,22 @@ describe('SupabaseStorageAdapter', () => {
206
241
 
207
242
  await expect(adapter.save(manifest)).rejects.toThrow('Failed to save run metadata');
208
243
  });
244
+
245
+ it('rejects malformed evaluator evidence before uploading a manifest', async () => {
246
+ const manifest = createMockManifest({
247
+ cases: [
248
+ {
249
+ ...createMockCaseResult('case-invalid-evidence', false),
250
+ evidence: { evaluator: 'custom', validation: { status: 'unknown' } },
251
+ } as never,
252
+ ],
253
+ });
254
+ const upload = mock(() => Promise.resolve({ error: null }));
255
+ mockStorage.mockReturnValue({ upload });
256
+
257
+ await expect(adapter.save(manifest)).rejects.toThrow('invalid evidence validation');
258
+ expect(upload).not.toHaveBeenCalled();
259
+ });
209
260
  });
210
261
 
211
262
  describe('load', () => {
@@ -253,6 +304,36 @@ describe('SupabaseStorageAdapter', () => {
253
304
 
254
305
  await expect(adapter.load('non-existent')).rejects.toThrow('Run not found');
255
306
  });
307
+
308
+ it('rejects a downloaded manifest with malformed evaluator evidence', async () => {
309
+ const manifest = createMockManifest({
310
+ cases: [
311
+ {
312
+ ...createMockCaseResult('case-invalid-evidence', false),
313
+ evidence: { evaluator: 'custom', score: Number.NaN },
314
+ } as never,
315
+ ],
316
+ });
317
+ mockFrom.mockReturnValue({
318
+ select: mock(() => ({
319
+ eq: mock(() => ({
320
+ single: mock(() =>
321
+ Promise.resolve({
322
+ data: { manifest_path: 'test-project/test-run-123.json' },
323
+ error: null,
324
+ })
325
+ ),
326
+ })),
327
+ })),
328
+ });
329
+ mockStorage.mockReturnValue({
330
+ download: mock(() =>
331
+ Promise.resolve({ data: new Blob([JSON.stringify(manifest)]), error: null })
332
+ ),
333
+ });
334
+
335
+ await expect(adapter.load('test-run-123')).rejects.toThrow('invalid evidence score');
336
+ });
256
337
  });
257
338
 
258
339
  describe('list', () => {
@@ -577,6 +658,34 @@ describe('SupabaseStorageAdapter', () => {
577
658
  expect(result).toBe('uuid-123');
578
659
  });
579
660
 
661
+ it('should retain an invalid case status with bounded evidence and attempts', async () => {
662
+ const caseResult = createMockCaseResultRecord({
663
+ status: 'invalid',
664
+ attempts: 2,
665
+ evidence: {
666
+ evaluator: 'llm_grader',
667
+ validation: { status: 'invalid', code: 'grader_failure' },
668
+ },
669
+ });
670
+ const mockUpsert = mock(() => ({
671
+ select: mock(() => ({
672
+ single: mock(() => Promise.resolve({ data: { id: 'uuid-123' }, error: null })),
673
+ })),
674
+ }));
675
+ mockFrom.mockReturnValue({ upsert: mockUpsert });
676
+
677
+ await adapter.saveCaseResult(caseResult);
678
+
679
+ expect(mockUpsert.mock.calls[0][0]).toMatchObject({
680
+ status: 'invalid',
681
+ attempts: 2,
682
+ evidence: {
683
+ evaluator: 'llm_grader',
684
+ validation: { status: 'invalid', code: 'grader_failure' },
685
+ },
686
+ });
687
+ });
688
+
580
689
  it('should throw error on save failure', async () => {
581
690
  const caseResult = createMockCaseResultRecord();
582
691
 
@@ -980,9 +1089,10 @@ describe('Type Safety', () => {
980
1089
  });
981
1090
 
982
1091
  it('should have valid CaseResultStatus types', () => {
983
- const statuses: CaseResultRecord['status'][] = ['passed', 'failed', 'error'];
1092
+ const statuses: CaseResultRecord['status'][] = ['passed', 'failed', 'invalid', 'error'];
984
1093
  expect(statuses).toContain('passed');
985
1094
  expect(statuses).toContain('failed');
1095
+ expect(statuses).toContain('invalid');
986
1096
  expect(statuses).toContain('error');
987
1097
  });
988
1098
  });
@@ -3,7 +3,12 @@
3
3
  */
4
4
 
5
5
  import { type SupabaseClient, createClient } from '@supabase/supabase-js';
6
- import type { CaseResult, RunManifest } from '../artifacts/types';
6
+ import {
7
+ type CaseResult,
8
+ type RunManifest,
9
+ assertRunManifestIntegrity,
10
+ getCaseEvaluationStatus,
11
+ } from '../artifacts/types';
7
12
  import type {
8
13
  AnalyticsStorageAdapter,
9
14
  BaselineMetadata,
@@ -31,7 +36,8 @@ function mapCaseToRecord(runId: string, caseResult: CaseResult): CaseResultRecor
31
36
  runId,
32
37
  caseId: caseResult.id,
33
38
  caseName: caseResult.name,
34
- status: caseResult.error ? 'error' : caseResult.ok ? 'passed' : 'failed',
39
+ status: getCaseEvaluationStatus(caseResult),
40
+ attempts: caseResult.attempts ?? 1,
35
41
  score: caseResult.score,
36
42
  matcherType: caseResult.matcherType,
37
43
  reason: caseResult.reason,
@@ -41,6 +47,7 @@ function mapCaseToRecord(runId: string, caseResult: CaseResult): CaseResultRecor
41
47
  completionTokens: caseResult.tokens.completion,
42
48
  totalTokens: caseResult.tokens.total,
43
49
  error: caseResult.error,
50
+ evidence: caseResult.evidence,
44
51
  tags: caseResult.tags,
45
52
  };
46
53
  }
@@ -61,6 +68,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
61
68
  // ============================================================================
62
69
 
63
70
  async save(manifest: RunManifest): Promise<string> {
71
+ assertRunManifestIntegrity(manifest);
64
72
  const filePath = `${manifest.project}/${manifest.run_id}.json`;
65
73
 
66
74
  const { error: uploadError } = await this.client.storage
@@ -84,6 +92,11 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
84
92
  total_cases: manifest.metrics.total_cases,
85
93
  passed_cases: manifest.metrics.passed_cases,
86
94
  failed_cases: manifest.metrics.failed_cases,
95
+ total_attempts: manifest.metrics.total_attempts ?? manifest.metrics.total_cases,
96
+ valid_evaluations: manifest.metrics.valid_evaluations ?? manifest.metrics.total_cases,
97
+ invalid_evaluations: manifest.metrics.invalid_evaluations ?? 0,
98
+ outcome_rate_denominator:
99
+ manifest.metrics.outcome_rate_denominator ?? manifest.metrics.total_cases,
87
100
  median_latency_ms: manifest.metrics.median_latency_ms,
88
101
  p95_latency_ms: manifest.metrics.p95_latency_ms,
89
102
  total_tokens: manifest.metrics.total_tokens,
@@ -130,7 +143,9 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
130
143
  }
131
144
 
132
145
  const text = await data.text();
133
- return JSON.parse(text);
146
+ const manifest: unknown = JSON.parse(text);
147
+ assertRunManifestIntegrity(manifest);
148
+ return manifest;
134
149
  }
135
150
 
136
151
  async list(options?: ListOptions): Promise<RunListItem[]> {
@@ -405,6 +420,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
405
420
  case_id: result.caseId,
406
421
  case_name: result.caseName,
407
422
  status: result.status,
423
+ attempts: result.attempts ?? 1,
408
424
  score: result.score,
409
425
  matcher_type: result.matcherType,
410
426
  reason: result.reason,
@@ -414,6 +430,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
414
430
  completion_tokens: result.completionTokens,
415
431
  total_tokens: result.totalTokens,
416
432
  error: result.error,
433
+ evidence: result.evidence,
417
434
  tags: result.tags || [],
418
435
  };
419
436
 
@@ -440,6 +457,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
440
457
  case_id: r.caseId,
441
458
  case_name: r.caseName,
442
459
  status: r.status,
460
+ attempts: r.attempts ?? 1,
443
461
  score: r.score,
444
462
  matcher_type: r.matcherType,
445
463
  reason: r.reason,
@@ -449,6 +467,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
449
467
  completion_tokens: r.completionTokens,
450
468
  total_tokens: r.totalTokens,
451
469
  error: r.error,
470
+ evidence: r.evidence,
452
471
  tags: r.tags || [],
453
472
  }));
454
473
 
@@ -481,6 +500,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
481
500
  caseId: r.case_id,
482
501
  caseName: r.case_name,
483
502
  status: r.status,
503
+ attempts: r.attempts,
484
504
  score: r.score,
485
505
  matcherType: r.matcher_type,
486
506
  reason: r.reason,
@@ -490,6 +510,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
490
510
  completionTokens: r.completion_tokens,
491
511
  totalTokens: r.total_tokens,
492
512
  error: r.error,
513
+ evidence: r.evidence,
493
514
  tags: r.tags,
494
515
  createdAt: r.created_at,
495
516
  }));
@@ -531,6 +552,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
531
552
  caseId: r.case_id,
532
553
  caseName: r.case_name,
533
554
  status: r.status,
555
+ attempts: r.attempts,
534
556
  score: r.score,
535
557
  matcherType: r.matcher_type,
536
558
  reason: r.reason,
@@ -540,6 +562,7 @@ export class SupabaseStorageAdapter implements AnalyticsStorageAdapter {
540
562
  completionTokens: r.completion_tokens,
541
563
  totalTokens: r.total_tokens,
542
564
  error: r.error,
565
+ evidence: r.evidence,
543
566
  tags: r.tags,
544
567
  createdAt: r.created_at,
545
568
  }));
@@ -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';