@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.
- package/CHANGELOG.md +17 -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 +52 -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 +20281 -13144
- 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/local.d.ts +1 -1
- package/dist/storage/local.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 +90 -19
- package/src/artifacts/manifest.ts +18 -5
- package/src/artifacts/types.ts +133 -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 +374 -0
- package/src/runner/executor.ts +349 -22
- package/src/runner/release-validation.test.ts +169 -0
- package/src/runner/runner.ts +7 -1
- package/src/runner/types.ts +4 -0
- package/src/scenario/schema.ts +56 -1
- package/src/storage/local.test.ts +24 -0
- package/src/storage/local.ts +13 -2
- package/src/storage/supabase.test.ts +111 -1
- package/src/storage/supabase.ts +26 -3
- 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
package/src/scenario/schema.ts
CHANGED
|
@@ -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
|
});
|
package/src/storage/local.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|
package/src/storage/supabase.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { type SupabaseClient, createClient } from '@supabase/supabase-js';
|
|
6
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
}));
|
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
|
+
}
|