@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModelClient } from '../adapters/types';
|
|
3
|
+
import { ScenarioSchema } from '../scenario/schema';
|
|
4
|
+
import { LLMGraderEvaluator } from './llm-grader';
|
|
5
|
+
|
|
6
|
+
function graderCase(strict: boolean) {
|
|
7
|
+
return ScenarioSchema.parse({
|
|
8
|
+
name: 'grader parsing',
|
|
9
|
+
cases: [
|
|
10
|
+
{
|
|
11
|
+
id: 'grade',
|
|
12
|
+
prompt: 'grade this',
|
|
13
|
+
expected: { type: 'llm_grader', rubric: 'Be correct', threshold: 0.7, strict },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}).cases[0].expected;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clientWith(text: string): ModelClient {
|
|
20
|
+
return {
|
|
21
|
+
provider: 'test',
|
|
22
|
+
generate: async () => ({
|
|
23
|
+
id: 'judge-response',
|
|
24
|
+
model: 'judge-model',
|
|
25
|
+
text,
|
|
26
|
+
tokens: { prompt: 1, completion: 1, total: 2 },
|
|
27
|
+
latencyMs: 1,
|
|
28
|
+
finishReason: 'stop',
|
|
29
|
+
}),
|
|
30
|
+
capabilities: async () => ({
|
|
31
|
+
streaming: false,
|
|
32
|
+
functionCalling: false,
|
|
33
|
+
toolUse: false,
|
|
34
|
+
maxContext: 1,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('LLMGraderEvaluator strict parsing', () => {
|
|
40
|
+
test('accepts finite numeric boundary scores in exact JSON', async () => {
|
|
41
|
+
const evaluator = new LLMGraderEvaluator();
|
|
42
|
+
const passing = await evaluator.evaluate('response', graderCase(true), {
|
|
43
|
+
client: clientWith('{"score":1,"reason":"complete"}'),
|
|
44
|
+
});
|
|
45
|
+
const failing = await evaluator.evaluate('response', graderCase(true), {
|
|
46
|
+
client: clientWith('{"score":0,"reason":"missing requirement"}'),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(passing).toMatchObject({ passed: true, score: 1, status: 'passed' });
|
|
50
|
+
expect(failing).toMatchObject({ passed: false, score: 0, status: 'failed' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('marks malformed, coerced, and out-of-range strict judge scores invalid', async () => {
|
|
54
|
+
const evaluator = new LLMGraderEvaluator();
|
|
55
|
+
for (const text of [
|
|
56
|
+
'Score: 1',
|
|
57
|
+
'{"score":"1","reason":"coerced"}',
|
|
58
|
+
'{"score":1.01,"reason":"out of range"}',
|
|
59
|
+
'```json\n{"score":1,"reason":"wrapped"}\n```',
|
|
60
|
+
]) {
|
|
61
|
+
const result = await evaluator.evaluate('response', graderCase(true), {
|
|
62
|
+
client: clientWith(text),
|
|
63
|
+
});
|
|
64
|
+
expect(result).toMatchObject({
|
|
65
|
+
passed: false,
|
|
66
|
+
score: 0,
|
|
67
|
+
status: 'invalid',
|
|
68
|
+
evidence: { validation: { status: 'invalid', code: 'grader_failure' } },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('keeps permissive parsing for non-strict legacy graders', async () => {
|
|
74
|
+
const result = await new LLMGraderEvaluator().evaluate('response', graderCase(false), {
|
|
75
|
+
client: clientWith('Score: 0.8'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result).toMatchObject({ passed: true, score: 0.8, status: 'passed' });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -58,18 +58,18 @@ export class LLMGraderEvaluator implements Evaluator {
|
|
|
58
58
|
maxTokens: 1000,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
const parsed = this.parseGraderResponse(result.text);
|
|
61
|
+
const parsed = this.parseGraderResponse(result.text, expected.strict);
|
|
62
62
|
const passed = parsed.score >= expected.threshold;
|
|
63
63
|
|
|
64
64
|
return {
|
|
65
65
|
passed,
|
|
66
66
|
score: parsed.score,
|
|
67
67
|
reason: parsed.reason || `Score: ${parsed.score.toFixed(2)}`,
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
rubric: expected.rubric,
|
|
68
|
+
status: passed ? 'passed' : 'failed',
|
|
69
|
+
evidence: {
|
|
71
70
|
threshold: expected.threshold,
|
|
72
71
|
model: result.model,
|
|
72
|
+
validation: { status: 'valid' },
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
75
|
} catch (error) {
|
|
@@ -77,12 +77,50 @@ export class LLMGraderEvaluator implements Evaluator {
|
|
|
77
77
|
passed: false,
|
|
78
78
|
score: 0,
|
|
79
79
|
reason: `Grader failed: ${(error as Error).message}`,
|
|
80
|
-
|
|
80
|
+
status: 'invalid',
|
|
81
|
+
evidence: {
|
|
82
|
+
threshold: expected.threshold,
|
|
83
|
+
validation: { status: 'invalid', code: 'grader_failure' },
|
|
84
|
+
},
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
private parseGraderResponse(text: string): { score: number; reason?: string } {
|
|
89
|
+
private parseGraderResponse(text: string, strict: boolean): { score: number; reason?: string } {
|
|
90
|
+
if (strict) {
|
|
91
|
+
return this.parseStrictGraderResponse(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return this.parseLegacyGraderResponse(text);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private parseStrictGraderResponse(text: string): { score: number; reason: string } {
|
|
98
|
+
let parsed: unknown;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(text);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error('Grader response must be an exact JSON object');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
!parsed ||
|
|
107
|
+
typeof parsed !== 'object' ||
|
|
108
|
+
Array.isArray(parsed) ||
|
|
109
|
+
typeof (parsed as { score?: unknown }).score !== 'number' ||
|
|
110
|
+
!Number.isFinite((parsed as { score: number }).score) ||
|
|
111
|
+
(parsed as { score: number }).score < 0 ||
|
|
112
|
+
(parsed as { score: number }).score > 1 ||
|
|
113
|
+
typeof (parsed as { reason?: unknown }).reason !== 'string'
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Grader response must contain a finite numeric score from 0 to 1 and a string reason'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return parsed as { score: number; reason: string };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private parseLegacyGraderResponse(text: string): { score: number; reason?: string } {
|
|
86
124
|
// Clean up the response - remove markdown code blocks if present
|
|
87
125
|
const cleanedText = text
|
|
88
126
|
.replace(/```json\s*/gi, '')
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { ToolTraceEvaluator } from './tool-trace';
|
|
3
|
+
|
|
4
|
+
const trace = ['lookup_customer', 'create_ticket'].map((name, step) => ({
|
|
5
|
+
step,
|
|
6
|
+
toolCall: { id: `call-${step}`, type: 'function' as const, function: { name, arguments: '{}' } },
|
|
7
|
+
latencyMs: 1,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('ToolTraceEvaluator', () => {
|
|
11
|
+
it('accepts required ordered calls', async () => {
|
|
12
|
+
await expect(
|
|
13
|
+
new ToolTraceEvaluator().evaluate(
|
|
14
|
+
'',
|
|
15
|
+
{
|
|
16
|
+
type: 'tool_trace',
|
|
17
|
+
requiredTools: ['lookup_customer', 'create_ticket'],
|
|
18
|
+
ordered: true,
|
|
19
|
+
maxCalls: 2,
|
|
20
|
+
},
|
|
21
|
+
{ toolTrace: trace }
|
|
22
|
+
)
|
|
23
|
+
).resolves.toMatchObject({ passed: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects missing, forbidden, and excessive calls', async () => {
|
|
27
|
+
const evaluator = new ToolTraceEvaluator();
|
|
28
|
+
await expect(
|
|
29
|
+
evaluator.evaluate(
|
|
30
|
+
'',
|
|
31
|
+
{ type: 'tool_trace', requiredTools: ['delete_customer'] },
|
|
32
|
+
{ toolTrace: trace }
|
|
33
|
+
)
|
|
34
|
+
).resolves.toMatchObject({
|
|
35
|
+
passed: false,
|
|
36
|
+
reason: 'Required tools not called: delete_customer',
|
|
37
|
+
});
|
|
38
|
+
await expect(
|
|
39
|
+
evaluator.evaluate(
|
|
40
|
+
'',
|
|
41
|
+
{ type: 'tool_trace', forbiddenTools: ['create_ticket'] },
|
|
42
|
+
{ toolTrace: trace }
|
|
43
|
+
)
|
|
44
|
+
).resolves.toMatchObject({ passed: false, reason: 'Forbidden tools called: create_ticket' });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Expected } from '../scenario/schema';
|
|
2
|
+
import type { Evaluator, EvaluatorContext, EvaluatorResult } from './types';
|
|
3
|
+
|
|
4
|
+
type ToolTraceExpected = Extract<Expected, { type: 'tool_trace' }>;
|
|
5
|
+
|
|
6
|
+
export class ToolTraceEvaluator implements Evaluator {
|
|
7
|
+
readonly type = 'tool_trace';
|
|
8
|
+
|
|
9
|
+
async evaluate(
|
|
10
|
+
_response: string,
|
|
11
|
+
expected: Expected,
|
|
12
|
+
context?: EvaluatorContext
|
|
13
|
+
): Promise<EvaluatorResult> {
|
|
14
|
+
const expectation = expected as ToolTraceExpected;
|
|
15
|
+
const calls = (context?.toolTrace ?? []).map((entry) => entry.toolCall.function.name);
|
|
16
|
+
const required = expectation.requiredTools ?? [];
|
|
17
|
+
const forbidden = expectation.forbiddenTools ?? [];
|
|
18
|
+
|
|
19
|
+
const missing = required.filter((name) => !calls.includes(name));
|
|
20
|
+
if (missing.length) return fail(`Required tools not called: ${missing.join(', ')}`, calls);
|
|
21
|
+
|
|
22
|
+
const presentForbidden = forbidden.filter((name) => calls.includes(name));
|
|
23
|
+
if (presentForbidden.length)
|
|
24
|
+
return fail(`Forbidden tools called: ${presentForbidden.join(', ')}`, calls);
|
|
25
|
+
|
|
26
|
+
if (expectation.maxCalls !== undefined && calls.length > expectation.maxCalls) {
|
|
27
|
+
return fail(`Tool calls exceeded limit of ${expectation.maxCalls}`, calls);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (expectation.ordered && !isOrdered(calls, required)) {
|
|
31
|
+
return fail('Required tools were not called in order', calls);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { passed: true, score: 1, reason: 'Tool trace matched expectation', details: { calls } };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isOrdered(calls: string[], required: string[]): boolean {
|
|
39
|
+
let start = 0;
|
|
40
|
+
for (const tool of required) {
|
|
41
|
+
const index = calls.indexOf(tool, start);
|
|
42
|
+
if (index === -1) return false;
|
|
43
|
+
start = index + 1;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(reason: string, calls: string[]): EvaluatorResult {
|
|
49
|
+
return { passed: false, score: 0, reason, details: { calls } };
|
|
50
|
+
}
|
package/src/evaluators/types.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ModelClient } from '../adapters/types';
|
|
6
6
|
import type { Expected, TestCase } from '../scenario/schema';
|
|
7
|
+
import type { ToolTraceEntry } from '../tools';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Context provided to evaluators
|
|
@@ -11,6 +12,7 @@ import type { Expected, TestCase } from '../scenario/schema';
|
|
|
11
12
|
export interface EvaluatorContext {
|
|
12
13
|
client?: ModelClient;
|
|
13
14
|
testCase?: TestCase;
|
|
15
|
+
toolTrace?: ToolTraceEntry[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -20,6 +22,24 @@ export interface EvaluatorResult {
|
|
|
20
22
|
passed: boolean;
|
|
21
23
|
score: number;
|
|
22
24
|
reason?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Whether this evaluator produced a valid measurement. Omitted by legacy
|
|
27
|
+
* evaluators; the executor derives it from `passed` for compatibility.
|
|
28
|
+
*/
|
|
29
|
+
status?: 'passed' | 'failed' | 'invalid';
|
|
30
|
+
/** Bounded evaluator metadata that may be retained in a run artifact. */
|
|
31
|
+
evidence?: {
|
|
32
|
+
threshold?: number;
|
|
33
|
+
model?: string;
|
|
34
|
+
validation?: {
|
|
35
|
+
status: 'valid' | 'invalid';
|
|
36
|
+
code?: string;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Legacy evaluator-private metadata. The standard executor never serializes
|
|
41
|
+
* this field; use `evidence` for reviewed, bounded artifact data.
|
|
42
|
+
*/
|
|
23
43
|
details?: Record<string, unknown>;
|
|
24
44
|
}
|
|
25
45
|
|
package/src/index.ts
CHANGED
|
@@ -33,5 +33,11 @@ export * from './redaction';
|
|
|
33
33
|
// Cost estimation
|
|
34
34
|
export * from './cost';
|
|
35
35
|
|
|
36
|
+
// Safe tool execution
|
|
37
|
+
export * from './tools';
|
|
38
|
+
|
|
39
|
+
// Real-agent evaluation contracts
|
|
40
|
+
export * from './agent-evaluation';
|
|
41
|
+
|
|
36
42
|
// Validator
|
|
37
43
|
export * from './validator';
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { GenerateOptions, ModelClient } from '../adapters/types';
|
|
3
|
+
import { registerEvaluator } from '../evaluators';
|
|
4
|
+
import type { Evaluator } from '../evaluators';
|
|
5
|
+
import { ScenarioSchema } from '../scenario/schema';
|
|
6
|
+
import { executeCase } from './executor';
|
|
7
|
+
|
|
8
|
+
function createEnabledToolLoopScenario() {
|
|
9
|
+
return ScenarioSchema.parse({
|
|
10
|
+
name: 'fixture-backed tool loop',
|
|
11
|
+
provider: 'ling',
|
|
12
|
+
model: 'Ling-3.0-flash',
|
|
13
|
+
setup: {
|
|
14
|
+
tools: [
|
|
15
|
+
{
|
|
16
|
+
type: 'function',
|
|
17
|
+
function: {
|
|
18
|
+
name: 'lookup_order',
|
|
19
|
+
parameters: { type: 'object', properties: { orderId: { type: 'string' } } },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
fixtures: {
|
|
24
|
+
lookup_order: [{ when: { orderId: 'A-1' }, result: { status: 'delivered' } }],
|
|
25
|
+
},
|
|
26
|
+
toolLoop: { enabled: true, maxSteps: 2 },
|
|
27
|
+
},
|
|
28
|
+
cases: [
|
|
29
|
+
{
|
|
30
|
+
id: 'order-status',
|
|
31
|
+
prompt: 'Where is order A-1?',
|
|
32
|
+
expected: { type: 'exact', value: 'Order A-1 was delivered.' },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createToolCallResponse() {
|
|
39
|
+
return {
|
|
40
|
+
id: 'first',
|
|
41
|
+
model: 'Ling-3.0-flash',
|
|
42
|
+
text: '',
|
|
43
|
+
tokens: { prompt: 7, completion: 2, total: 9 },
|
|
44
|
+
latencyMs: 4,
|
|
45
|
+
finishReason: 'tool_calls' as const,
|
|
46
|
+
toolCalls: [
|
|
47
|
+
{
|
|
48
|
+
id: 'call-order',
|
|
49
|
+
type: 'function' as const,
|
|
50
|
+
function: { name: 'lookup_order', arguments: '{"orderId":"A-1"}' },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('executeCase tool loop', () => {
|
|
57
|
+
it('preserves tool call IDs while resolving fixture-backed calls', async () => {
|
|
58
|
+
const scenario = createEnabledToolLoopScenario();
|
|
59
|
+
const requests: GenerateOptions[] = [];
|
|
60
|
+
const responses = [
|
|
61
|
+
{
|
|
62
|
+
id: 'first',
|
|
63
|
+
model: 'Ling-3.0-flash',
|
|
64
|
+
text: '',
|
|
65
|
+
tokens: { prompt: 10, completion: 3, total: 13 },
|
|
66
|
+
latencyMs: 1,
|
|
67
|
+
finishReason: 'tool_calls' as const,
|
|
68
|
+
toolCalls: [
|
|
69
|
+
{
|
|
70
|
+
id: 'call-order',
|
|
71
|
+
type: 'function' as const,
|
|
72
|
+
function: { name: 'lookup_order', arguments: '{"orderId":"A-1"}' },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'second',
|
|
78
|
+
model: 'Ling-3.0-flash',
|
|
79
|
+
text: 'Order A-1 was delivered.',
|
|
80
|
+
tokens: { prompt: 20, completion: 5, total: 25 },
|
|
81
|
+
latencyMs: 1,
|
|
82
|
+
finishReason: 'stop' as const,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
const client: ModelClient = {
|
|
86
|
+
provider: 'ling',
|
|
87
|
+
generate: async (options) => {
|
|
88
|
+
requests.push(options);
|
|
89
|
+
const response = responses.shift();
|
|
90
|
+
if (!response) throw new Error('Unexpected generation request');
|
|
91
|
+
return response;
|
|
92
|
+
},
|
|
93
|
+
capabilities: async () => ({
|
|
94
|
+
streaming: true,
|
|
95
|
+
functionCalling: true,
|
|
96
|
+
toolUse: true,
|
|
97
|
+
maxContext: 256000,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = await executeCase(scenario.cases[0], { client, scenario });
|
|
102
|
+
|
|
103
|
+
expect(result.ok).toBe(true);
|
|
104
|
+
expect(result.latencyMs).toBe(2);
|
|
105
|
+
expect(result.tokens).toEqual({ prompt: 30, completion: 8, total: 38 });
|
|
106
|
+
expect(requests).toHaveLength(2);
|
|
107
|
+
expect(requests[1].prompt).toEqual([
|
|
108
|
+
{ role: 'user', content: 'Where is order A-1?' },
|
|
109
|
+
{
|
|
110
|
+
role: 'assistant',
|
|
111
|
+
content: '',
|
|
112
|
+
tool_calls: [
|
|
113
|
+
{
|
|
114
|
+
id: 'call-order',
|
|
115
|
+
type: 'function',
|
|
116
|
+
function: { name: 'lookup_order', arguments: '{"orderId":"A-1"}' },
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
role: 'tool',
|
|
122
|
+
name: 'lookup_order',
|
|
123
|
+
toolCallId: 'call-order',
|
|
124
|
+
content: '{"status":"delivered"}',
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('retains generation metrics when the tool loop cannot start', async () => {
|
|
130
|
+
const scenario = ScenarioSchema.parse({
|
|
131
|
+
name: 'invalid tool loop',
|
|
132
|
+
provider: 'ling',
|
|
133
|
+
model: 'Ling-3.0-flash',
|
|
134
|
+
setup: { toolLoop: { enabled: true } },
|
|
135
|
+
cases: [
|
|
136
|
+
{
|
|
137
|
+
id: 'missing-tools',
|
|
138
|
+
prompt: 'Use a tool',
|
|
139
|
+
expected: { type: 'exact', value: '' },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
const client: ModelClient = {
|
|
144
|
+
provider: 'ling',
|
|
145
|
+
generate: async () => ({
|
|
146
|
+
id: 'first',
|
|
147
|
+
model: 'Ling-3.0-flash',
|
|
148
|
+
text: '',
|
|
149
|
+
tokens: { prompt: 7, completion: 2, total: 9 },
|
|
150
|
+
latencyMs: 4,
|
|
151
|
+
finishReason: 'tool_calls',
|
|
152
|
+
toolCalls: [
|
|
153
|
+
{
|
|
154
|
+
id: 'call-order',
|
|
155
|
+
type: 'function',
|
|
156
|
+
function: { name: 'lookup_order', arguments: '{"orderId":"A-1"}' },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}),
|
|
160
|
+
capabilities: async () => ({
|
|
161
|
+
streaming: true,
|
|
162
|
+
functionCalling: true,
|
|
163
|
+
toolUse: true,
|
|
164
|
+
maxContext: 256000,
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const result = await executeCase(scenario.cases[0], { client, scenario });
|
|
169
|
+
|
|
170
|
+
expect(result.error).toBe('TOOL_EXECUTOR_REQUIRED');
|
|
171
|
+
expect(result.latencyMs).toBe(4);
|
|
172
|
+
expect(result.tokens).toEqual({ prompt: 7, completion: 2, total: 9 });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('retains prior generation metrics when a later generation rejects', async () => {
|
|
176
|
+
const scenario = createEnabledToolLoopScenario();
|
|
177
|
+
let calls = 0;
|
|
178
|
+
const client: ModelClient = {
|
|
179
|
+
provider: 'ling',
|
|
180
|
+
generate: async () => {
|
|
181
|
+
calls++;
|
|
182
|
+
if (calls === 1) return createToolCallResponse();
|
|
183
|
+
throw new Error('later generation failed');
|
|
184
|
+
},
|
|
185
|
+
capabilities: async () => ({
|
|
186
|
+
streaming: true,
|
|
187
|
+
functionCalling: true,
|
|
188
|
+
toolUse: true,
|
|
189
|
+
maxContext: 256000,
|
|
190
|
+
}),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const result = await executeCase(scenario.cases[0], { client, scenario });
|
|
194
|
+
|
|
195
|
+
expect(calls).toBe(2);
|
|
196
|
+
expect(result.error).toBe('TOOL_GENERATION_FAILED');
|
|
197
|
+
expect(result.latencyMs).toBe(4);
|
|
198
|
+
expect(result.tokens).toEqual({ prompt: 7, completion: 2, total: 9 });
|
|
199
|
+
expect(result.toolLoop).toEqual({
|
|
200
|
+
status: 'error',
|
|
201
|
+
steps: 1,
|
|
202
|
+
terminationReason: 'tool_error',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('retains prior generation metrics when a later generation times out', async () => {
|
|
207
|
+
const scenario = createEnabledToolLoopScenario();
|
|
208
|
+
let calls = 0;
|
|
209
|
+
const client: ModelClient = {
|
|
210
|
+
provider: 'ling',
|
|
211
|
+
generate: async () => {
|
|
212
|
+
calls++;
|
|
213
|
+
if (calls === 1) return createToolCallResponse();
|
|
214
|
+
return await new Promise<never>(() => {});
|
|
215
|
+
},
|
|
216
|
+
capabilities: async () => ({
|
|
217
|
+
streaming: true,
|
|
218
|
+
functionCalling: true,
|
|
219
|
+
toolUse: true,
|
|
220
|
+
maxContext: 256000,
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const result = await executeCase(scenario.cases[0], { client, scenario, timeout: 10 });
|
|
225
|
+
|
|
226
|
+
expect(calls).toBe(2);
|
|
227
|
+
expect(result.error).toBe('TOOL_LOOP_TIMEOUT');
|
|
228
|
+
expect(result.latencyMs).toBe(4);
|
|
229
|
+
expect(result.tokens).toEqual({ prompt: 7, completion: 2, total: 9 });
|
|
230
|
+
expect(result.toolLoop).toEqual({
|
|
231
|
+
status: 'error',
|
|
232
|
+
steps: 1,
|
|
233
|
+
terminationReason: 'timeout',
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('executeCase measurement integrity', () => {
|
|
239
|
+
const scenario = ScenarioSchema.parse({
|
|
240
|
+
name: 'measurement integrity',
|
|
241
|
+
cases: [
|
|
242
|
+
{
|
|
243
|
+
id: 'custom-evaluation',
|
|
244
|
+
prompt: 'Evaluate this',
|
|
245
|
+
expected: { type: 'custom', evaluator: 'test' },
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const client: ModelClient = {
|
|
251
|
+
provider: 'test',
|
|
252
|
+
generate: async () => ({
|
|
253
|
+
id: 'response',
|
|
254
|
+
model: 'target-model',
|
|
255
|
+
text: 'target response',
|
|
256
|
+
tokens: { prompt: 1, completion: 1, total: 2 },
|
|
257
|
+
latencyMs: 1,
|
|
258
|
+
finishReason: 'stop',
|
|
259
|
+
}),
|
|
260
|
+
capabilities: async () => ({
|
|
261
|
+
streaming: false,
|
|
262
|
+
functionCalling: false,
|
|
263
|
+
toolUse: false,
|
|
264
|
+
maxContext: 1,
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
it('marks an evaluator exception invalid after a target response is received', async () => {
|
|
269
|
+
registerEvaluator('custom', {
|
|
270
|
+
type: 'custom',
|
|
271
|
+
evaluate: async () => {
|
|
272
|
+
throw new Error('judge offline');
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await executeCase(scenario.cases[0], { client, scenario });
|
|
277
|
+
|
|
278
|
+
expect(result).toMatchObject({
|
|
279
|
+
ok: false,
|
|
280
|
+
status: 'invalid',
|
|
281
|
+
response: 'target response',
|
|
282
|
+
evidence: {
|
|
283
|
+
evaluator: 'custom',
|
|
284
|
+
validation: { status: 'invalid', code: 'evaluator_failure' },
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('marks a target generation failure as an execution error', async () => {
|
|
290
|
+
const unavailableClient: ModelClient = {
|
|
291
|
+
...client,
|
|
292
|
+
generate: async () => {
|
|
293
|
+
throw new Error('provider unavailable');
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const result = await executeCase(scenario.cases[0], {
|
|
298
|
+
client: unavailableClient,
|
|
299
|
+
scenario,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
expect(result).toMatchObject({
|
|
303
|
+
ok: false,
|
|
304
|
+
status: 'error',
|
|
305
|
+
response: '',
|
|
306
|
+
error: 'provider unavailable',
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('retains only the bounded evidence contract rather than evaluator details', async () => {
|
|
311
|
+
const evaluator: Evaluator = {
|
|
312
|
+
type: 'custom',
|
|
313
|
+
evaluate: async () => ({
|
|
314
|
+
passed: true,
|
|
315
|
+
score: 1,
|
|
316
|
+
status: 'passed',
|
|
317
|
+
evidence: {
|
|
318
|
+
threshold: 0.7,
|
|
319
|
+
model: 'reviewer-model',
|
|
320
|
+
validation: { status: 'valid', code: 'accepted' },
|
|
321
|
+
},
|
|
322
|
+
details: { rawJudgeOutput: 'secret judge transcript', rubric: 'secret rubric' },
|
|
323
|
+
}),
|
|
324
|
+
};
|
|
325
|
+
registerEvaluator('custom', evaluator);
|
|
326
|
+
|
|
327
|
+
const result = await executeCase(scenario.cases[0], { client, scenario });
|
|
328
|
+
|
|
329
|
+
expect(result.status).toBe('passed');
|
|
330
|
+
expect(result.evidence).toEqual({
|
|
331
|
+
evaluator: 'custom',
|
|
332
|
+
score: 1,
|
|
333
|
+
threshold: 0.7,
|
|
334
|
+
model: 'reviewer-model',
|
|
335
|
+
validation: { status: 'valid', code: 'accepted' },
|
|
336
|
+
});
|
|
337
|
+
expect(JSON.stringify(result)).not.toContain('secret judge transcript');
|
|
338
|
+
expect(JSON.stringify(result)).not.toContain('secret rubric');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('redacts evaluator reason text and discards malformed runtime evidence', async () => {
|
|
342
|
+
registerEvaluator('custom', {
|
|
343
|
+
type: 'custom',
|
|
344
|
+
evaluate: async () =>
|
|
345
|
+
({
|
|
346
|
+
passed: false,
|
|
347
|
+
score: 0.4,
|
|
348
|
+
status: 'invalid-status',
|
|
349
|
+
reason: 'judge_error: token=super-secret-token',
|
|
350
|
+
evidence: {
|
|
351
|
+
threshold: '0.7',
|
|
352
|
+
model: 'reviewer@example.com',
|
|
353
|
+
validation: { status: 'unknown', code: 'token=super-secret-token' },
|
|
354
|
+
},
|
|
355
|
+
}) as never,
|
|
356
|
+
});
|
|
357
|
+
const redactedScenario = ScenarioSchema.parse({
|
|
358
|
+
...scenario,
|
|
359
|
+
redaction: { enabled: true, patterns: ['email', 'secrets'] },
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const result = await executeCase(redactedScenario.cases[0], {
|
|
363
|
+
client,
|
|
364
|
+
scenario: redactedScenario,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(result.status).toBe('failed');
|
|
368
|
+
expect(result.reason).toContain('[REDACTED]');
|
|
369
|
+
expect(result.evidence).toEqual({ evaluator: 'custom', score: 0.4, model: '[REDACTED]' });
|
|
370
|
+
expect(result.redaction).toMatchObject({ redacted: true, reasonRedacted: true });
|
|
371
|
+
expect(JSON.stringify(result)).not.toContain('super-secret-token');
|
|
372
|
+
expect(JSON.stringify(result)).not.toContain('reviewer@example.com');
|
|
373
|
+
});
|
|
374
|
+
});
|