@iris-eval/mcp-server 0.7.0 → 0.8.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/README.md +4 -3
- package/dist/config/defaults.js +14 -0
- package/dist/config/index.js +8 -0
- package/dist/dashboard/assets/index-BfMShR3p.js +10 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/rules.d.ts +8 -11
- package/dist/dashboard/routes/rules.js +9 -16
- package/dist/dashboard/routes/traces.js +6 -0
- package/dist/dashboard/validation.d.ts +3 -3
- package/dist/eval/criticality.d.ts +67 -0
- package/dist/eval/criticality.js +154 -0
- package/dist/eval/engine.d.ts +16 -1
- package/dist/eval/engine.js +33 -5
- package/dist/eval/rules/cost.d.ts +9 -0
- package/dist/eval/rules/cost.js +97 -1
- package/dist/eval/rules/safety.d.ts +1 -0
- package/dist/eval/rules/safety.js +73 -1
- package/dist/eval/rules/trajectory.d.ts +91 -0
- package/dist/eval/rules/trajectory.js +297 -0
- package/dist/index.js +1 -1
- package/dist/self-test.js +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/evaluate-output.js +21 -8
- package/dist/tools/index.js +1 -1
- package/dist/tools/list-rules.d.ts +2 -1
- package/dist/tools/list-rules.js +22 -4
- package/dist/tools/log-trace.d.ts +8 -1
- package/dist/tools/log-trace.js +19 -4
- package/dist/tools/trace-link.d.ts +10 -0
- package/dist/tools/trace-link.js +13 -1
- package/dist/types/config.d.ts +14 -0
- package/dist/types/eval.d.ts +30 -5
- package/package.json +2 -1
- package/server.json +51 -2
- package/dist/dashboard/assets/index-CKs2Wbd_.js +0 -10
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<!-- Stop shipping agents on vibes is filled from .claims.json brand.tagline at build time (vite.config.ts) — never restate the tagline here. -->
|
|
8
8
|
<title>Iris — Stop shipping agents on vibes</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BfMShR3p.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-D0cFfBqn.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -2,20 +2,18 @@ import { Router } from 'express';
|
|
|
2
2
|
import type { IStorageAdapter } from '../../types/query.js';
|
|
3
3
|
import type { CustomRuleStore } from '../../custom-rule-store.js';
|
|
4
4
|
import type { EvalEngine } from '../../eval/engine.js';
|
|
5
|
-
import type
|
|
5
|
+
import { type BuiltInRuleMeta } from '../../eval/criticality.js';
|
|
6
6
|
interface RoutesOptions {
|
|
7
7
|
customRuleStore: CustomRuleStore;
|
|
8
8
|
evalEngine: EvalEngine;
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
export declare function listBuiltInRules(): BuiltInRuleMeta[];
|
|
10
|
+
export type { BuiltInRuleMeta };
|
|
11
|
+
/**
|
|
12
|
+
* The roster as the dashboard asks for it. Passing the engine is what makes
|
|
13
|
+
* `critical` the value this server will actually apply; omitting it returns
|
|
14
|
+
* the rules' own declarations, reported as source 'default'.
|
|
15
|
+
*/
|
|
16
|
+
export declare function listBuiltInRules(engine?: EvalEngine): BuiltInRuleMeta[];
|
|
19
17
|
export declare function registerRuleRoutes(router: Router, storage: IStorageAdapter, opts: RoutesOptions): void;
|
|
20
18
|
/** Verdict of the proposed rule against the caller's own sample text. */
|
|
21
19
|
export interface SamplePreview {
|
|
@@ -25,4 +23,3 @@ export interface SamplePreview {
|
|
|
25
23
|
skipped: boolean;
|
|
26
24
|
skipReason?: string;
|
|
27
25
|
}
|
|
28
|
-
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { createCustomRule } from '../../eval/rules/custom.js';
|
|
3
|
-
import {
|
|
3
|
+
import { builtInRuleRoster } from '../../eval/criticality.js';
|
|
4
4
|
import { requireTenant } from '../../middleware/tenant.js';
|
|
5
5
|
import { DuplicateRuleNameError, replacedRulesWarning, retireSameNamedRules } from '../../tools/deploy-rule.js';
|
|
6
6
|
import { strictBody } from '../validation.js';
|
|
@@ -77,20 +77,13 @@ const PreviewSchema = strictBody({
|
|
|
77
77
|
const ToggleSchema = strictBody({
|
|
78
78
|
enabled: z.boolean(),
|
|
79
79
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
description: rule.description,
|
|
88
|
-
weight: rule.weight,
|
|
89
|
-
critical: rule.critical === true,
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
80
|
+
/**
|
|
81
|
+
* The roster as the dashboard asks for it. Passing the engine is what makes
|
|
82
|
+
* `critical` the value this server will actually apply; omitting it returns
|
|
83
|
+
* the rules' own declarations, reported as source 'default'.
|
|
84
|
+
*/
|
|
85
|
+
export function listBuiltInRules(engine) {
|
|
86
|
+
return builtInRuleRoster(engine ? (rule) => engine.effectiveCriticality(rule) : undefined);
|
|
94
87
|
}
|
|
95
88
|
export function registerRuleRoutes(router, storage, opts) {
|
|
96
89
|
/*
|
|
@@ -112,7 +105,7 @@ export function registerRuleRoutes(router, storage, opts) {
|
|
|
112
105
|
* are process-global.
|
|
113
106
|
*/
|
|
114
107
|
router.get('/rules/builtin', (_req, res) => {
|
|
115
|
-
res.json({ rules: listBuiltInRules() });
|
|
108
|
+
res.json({ rules: listBuiltInRules(opts.evalEngine) });
|
|
116
109
|
});
|
|
117
110
|
router.get('/rules/custom', (req, res) => {
|
|
118
111
|
const tenantId = requireTenant(req);
|
|
@@ -68,6 +68,12 @@ export function registerTraceRoutes(router, storage, options) {
|
|
|
68
68
|
input: body.input,
|
|
69
69
|
costUsd: body.cost_usd,
|
|
70
70
|
tokenUsage: body.token_usage,
|
|
71
|
+
// The trajectory the SAME request just stored. This body already
|
|
72
|
+
// carries what the agent did; not forwarding it made every
|
|
73
|
+
// trajectory rule skip on the one path where the data was
|
|
74
|
+
// guaranteed present — an ingest that captured a failed tool call
|
|
75
|
+
// and then evaluated as though it had never been told.
|
|
76
|
+
toolCalls: body.tool_calls,
|
|
71
77
|
};
|
|
72
78
|
// An omitted eval_type runs every bundle — the same default, from the
|
|
73
79
|
// same constant, as the MCP tool — and says so in the response.
|
|
@@ -22,7 +22,7 @@ export declare const ingestTraceSchema: z.ZodObject<{
|
|
|
22
22
|
output: z.ZodOptional<z.ZodUnknown>;
|
|
23
23
|
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
24
24
|
error: z.ZodOptional<z.ZodString>;
|
|
25
|
-
}, z.core.$
|
|
25
|
+
}, z.core.$strict>>>;
|
|
26
26
|
latency_ms: z.ZodOptional<z.ZodNumber>;
|
|
27
27
|
token_usage: z.ZodOptional<z.ZodObject<{
|
|
28
28
|
prompt_tokens: z.ZodOptional<z.ZodNumber>;
|
|
@@ -83,9 +83,9 @@ export declare const traceQuerySchema: z.ZodObject<{
|
|
|
83
83
|
export declare const evalQuerySchema: z.ZodObject<{
|
|
84
84
|
eval_type: z.ZodOptional<z.ZodString>;
|
|
85
85
|
passed: z.ZodOptional<z.ZodPipe<z.ZodEnum<{
|
|
86
|
-
true: "true";
|
|
87
86
|
false: "false";
|
|
88
|
-
|
|
87
|
+
true: "true";
|
|
88
|
+
}>, z.ZodTransform<boolean, "false" | "true">>>;
|
|
89
89
|
since: z.ZodOptional<z.ZodString>;
|
|
90
90
|
until: z.ZodOptional<z.ZodString>;
|
|
91
91
|
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EvalRule, EvalType } from '../types/eval.js';
|
|
2
|
+
/** Where a rule's EFFECTIVE criticality came from. */
|
|
3
|
+
export type CriticalitySource = 'default' | 'config';
|
|
4
|
+
export interface CriticalityOverrides {
|
|
5
|
+
/** Built-in rule names promoted to critical. */
|
|
6
|
+
criticalRules?: string[];
|
|
7
|
+
/** Built-in rule names demoted from critical. */
|
|
8
|
+
nonCriticalRules?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface EffectiveCriticality {
|
|
11
|
+
critical: boolean;
|
|
12
|
+
/** 'config' when one of the two lists decided this; 'default' otherwise. */
|
|
13
|
+
source: CriticalitySource;
|
|
14
|
+
}
|
|
15
|
+
/** Every built-in rule, in bundle order. The registry the lists are checked against. */
|
|
16
|
+
export declare function builtInRules(): EvalRule[];
|
|
17
|
+
/** Built-in rule names, sorted — the "valid list" an error message prints. */
|
|
18
|
+
export declare function builtInRuleNames(): string[];
|
|
19
|
+
/**
|
|
20
|
+
* Every problem with a pair of override lists, as human sentences.
|
|
21
|
+
* Empty means valid. Separate from the thrower so a caller that wants to
|
|
22
|
+
* report several at once (a config linter, a test) can.
|
|
23
|
+
*/
|
|
24
|
+
export declare function criticalityIssues(overrides: CriticalityOverrides | undefined): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Throw on any problem, with every problem named at once.
|
|
27
|
+
*
|
|
28
|
+
* Called from loadConfig (so a bad config.json fails at startup, before a
|
|
29
|
+
* single evaluation runs) and from the EvalEngine constructor (so no code
|
|
30
|
+
* path can build an engine that silently ignores an override).
|
|
31
|
+
*/
|
|
32
|
+
export declare function assertValidCriticality(overrides: CriticalityOverrides | undefined): void;
|
|
33
|
+
/**
|
|
34
|
+
* The effective criticality of one rule.
|
|
35
|
+
*
|
|
36
|
+
* A rule the overrides do not name keeps its definition's `critical` and
|
|
37
|
+
* reports source 'default' — which for a deployed custom rule means the
|
|
38
|
+
* severity it was deployed with.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveCriticality(rule: EvalRule, overrides: CriticalityOverrides | undefined, builtIns: ReadonlySet<EvalRule>): EffectiveCriticality;
|
|
41
|
+
/** A resolver bound to one set of overrides, so callers don't rebuild the identity set per rule. */
|
|
42
|
+
export declare function criticalityResolver(overrides: CriticalityOverrides | undefined): (rule: EvalRule) => EffectiveCriticality;
|
|
43
|
+
/** Built-in rule metadata as every roster surface reports it — derived from the registry, never restated. */
|
|
44
|
+
export interface BuiltInRuleMeta {
|
|
45
|
+
name: string;
|
|
46
|
+
category: EvalType;
|
|
47
|
+
description: string;
|
|
48
|
+
weight: number;
|
|
49
|
+
/** EFFECTIVE criticality, after eval.criticalRules / eval.nonCriticalRules. */
|
|
50
|
+
critical: boolean;
|
|
51
|
+
/** Who decided it: the rule's own declaration, or one of the config lists. */
|
|
52
|
+
criticalSource: CriticalitySource;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The whole built-in roster, one entry per rule.
|
|
56
|
+
*
|
|
57
|
+
* `resolve` is what makes `critical` EFFECTIVE rather than declared. Reading
|
|
58
|
+
* `rule.critical` on a roster while the engine applies an override would
|
|
59
|
+
* show an operator a list that disagrees with the verdicts the same process
|
|
60
|
+
* is producing — they would see `no_silent_tool_failure` reported
|
|
61
|
+
* non-critical while it vetoed their pipeline. Callers that only want names
|
|
62
|
+
* and weights may omit it and get the declarations, reported as 'default'.
|
|
63
|
+
*
|
|
64
|
+
* Typed on a function, not on EvalEngine, so this module stays free of the
|
|
65
|
+
* engine it is imported by.
|
|
66
|
+
*/
|
|
67
|
+
export declare function builtInRuleRoster(resolve?: (rule: EvalRule) => EffectiveCriticality): BuiltInRuleMeta[];
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which built-in rules VETO — a deployment's decision, not ours.
|
|
3
|
+
*
|
|
4
|
+
* `critical` is a property of each rule's definition, and until now that was
|
|
5
|
+
* the whole story: a failing critical rule forced `passed: false`, a failing
|
|
6
|
+
* non-critical one only moved the score, and nobody running Iris could
|
|
7
|
+
* change either. That default is a judgement about acceptable error, and it
|
|
8
|
+
* is not ours to make for everyone. The trajectory release is the plain
|
|
9
|
+
* case: `no_silent_tool_failure` catches an agent answering over a tool that
|
|
10
|
+
* errored, which is exactly what a team gating deploys wants to block — but
|
|
11
|
+
* its precision on a 30-case family carries a 95% lower bound of 77.2%, so
|
|
12
|
+
* shipping it as a veto for everyone would force false failures on people
|
|
13
|
+
* who never asked for that trade. A team that HAS looked at the number can
|
|
14
|
+
* make the call for their own pipeline; the config keys below are how.
|
|
15
|
+
*
|
|
16
|
+
* Two lists, both optional, both naming BUILT-IN rules:
|
|
17
|
+
* eval.criticalRules — promote: these rules veto `passed`.
|
|
18
|
+
* eval.nonCriticalRules — demote: these rules stop vetoing.
|
|
19
|
+
*
|
|
20
|
+
* Every name is checked against the rule registry when the config loads and
|
|
21
|
+
* again when an engine is constructed. An unknown name is a startup error
|
|
22
|
+
* naming the valid list, never a silent no-op: a typo in `criticalRules`
|
|
23
|
+
* that quietly did nothing would leave an operator believing a gate exists
|
|
24
|
+
* when it does not, which is the same "detection that reports an all-clear"
|
|
25
|
+
* failure the critical veto was built to stop.
|
|
26
|
+
*
|
|
27
|
+
* Overrides are matched by rule IDENTITY, not by name. Deployed custom rules
|
|
28
|
+
* do not enforce unique names — one can legitimately be called `no_pii` —
|
|
29
|
+
* and a name-keyed override would silently reach it. A custom rule's
|
|
30
|
+
* severity stays its own definition's business.
|
|
31
|
+
*/
|
|
32
|
+
import { rulesByType } from './rules/index.js';
|
|
33
|
+
/** Every built-in rule, in bundle order. The registry the lists are checked against. */
|
|
34
|
+
export function builtInRules() {
|
|
35
|
+
return ['completeness', 'relevance', 'safety', 'cost'].flatMap((t) => rulesByType[t]);
|
|
36
|
+
}
|
|
37
|
+
/** Built-in rule names, sorted — the "valid list" an error message prints. */
|
|
38
|
+
export function builtInRuleNames() {
|
|
39
|
+
return builtInRules()
|
|
40
|
+
.map((r) => r.name)
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Every problem with a pair of override lists, as human sentences.
|
|
45
|
+
* Empty means valid. Separate from the thrower so a caller that wants to
|
|
46
|
+
* report several at once (a config linter, a test) can.
|
|
47
|
+
*/
|
|
48
|
+
export function criticalityIssues(overrides) {
|
|
49
|
+
if (!overrides)
|
|
50
|
+
return [];
|
|
51
|
+
const issues = [];
|
|
52
|
+
const valid = new Set(builtInRuleNames());
|
|
53
|
+
const seen = {
|
|
54
|
+
criticalRules: new Set(),
|
|
55
|
+
nonCriticalRules: new Set(),
|
|
56
|
+
};
|
|
57
|
+
for (const key of ['criticalRules', 'nonCriticalRules']) {
|
|
58
|
+
const list = overrides[key];
|
|
59
|
+
if (list === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
if (!Array.isArray(list)) {
|
|
62
|
+
issues.push(`eval.${key} must be an array of built-in rule names.`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const entry of list) {
|
|
66
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
67
|
+
issues.push(`eval.${key} contains ${JSON.stringify(entry)}, which is not a rule name.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!valid.has(entry)) {
|
|
71
|
+
issues.push(`eval.${key} names "${entry}", which is not a built-in rule. ` +
|
|
72
|
+
`Valid names: ${builtInRuleNames().join(', ')}. ` +
|
|
73
|
+
'Deployed custom rules carry their own severity and are not set here.');
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
seen[key].add(entry);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const name of seen.criticalRules) {
|
|
80
|
+
if (seen.nonCriticalRules.has(name)) {
|
|
81
|
+
issues.push(`"${name}" is in BOTH eval.criticalRules and eval.nonCriticalRules, so the config does not say whether it should veto. Remove it from one of them.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return issues;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Throw on any problem, with every problem named at once.
|
|
88
|
+
*
|
|
89
|
+
* Called from loadConfig (so a bad config.json fails at startup, before a
|
|
90
|
+
* single evaluation runs) and from the EvalEngine constructor (so no code
|
|
91
|
+
* path can build an engine that silently ignores an override).
|
|
92
|
+
*/
|
|
93
|
+
export function assertValidCriticality(overrides) {
|
|
94
|
+
const issues = criticalityIssues(overrides);
|
|
95
|
+
if (issues.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
throw new Error(`Invalid eval criticality configuration:\n - ${issues.join('\n - ')}\n` +
|
|
98
|
+
'Set eval.criticalRules / eval.nonCriticalRules in your Iris config.json (see docs/api-reference.md § Rule criticality).');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The effective criticality of one rule.
|
|
102
|
+
*
|
|
103
|
+
* A rule the overrides do not name keeps its definition's `critical` and
|
|
104
|
+
* reports source 'default' — which for a deployed custom rule means the
|
|
105
|
+
* severity it was deployed with.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveCriticality(rule, overrides, builtIns) {
|
|
108
|
+
const declared = rule.critical === true;
|
|
109
|
+
if (!overrides || !builtIns.has(rule))
|
|
110
|
+
return { critical: declared, source: 'default' };
|
|
111
|
+
if (overrides.criticalRules?.includes(rule.name))
|
|
112
|
+
return { critical: true, source: 'config' };
|
|
113
|
+
if (overrides.nonCriticalRules?.includes(rule.name))
|
|
114
|
+
return { critical: false, source: 'config' };
|
|
115
|
+
return { critical: declared, source: 'default' };
|
|
116
|
+
}
|
|
117
|
+
/** A resolver bound to one set of overrides, so callers don't rebuild the identity set per rule. */
|
|
118
|
+
export function criticalityResolver(overrides) {
|
|
119
|
+
assertValidCriticality(overrides);
|
|
120
|
+
const builtIns = new Set(builtInRules());
|
|
121
|
+
return (rule) => resolveCriticality(rule, overrides, builtIns);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The whole built-in roster, one entry per rule.
|
|
125
|
+
*
|
|
126
|
+
* `resolve` is what makes `critical` EFFECTIVE rather than declared. Reading
|
|
127
|
+
* `rule.critical` on a roster while the engine applies an override would
|
|
128
|
+
* show an operator a list that disagrees with the verdicts the same process
|
|
129
|
+
* is producing — they would see `no_silent_tool_failure` reported
|
|
130
|
+
* non-critical while it vetoed their pipeline. Callers that only want names
|
|
131
|
+
* and weights may omit it and get the declarations, reported as 'default'.
|
|
132
|
+
*
|
|
133
|
+
* Typed on a function, not on EvalEngine, so this module stays free of the
|
|
134
|
+
* engine it is imported by.
|
|
135
|
+
*/
|
|
136
|
+
export function builtInRuleRoster(resolve) {
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const [category, rules] of Object.entries(rulesByType)) {
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
const effective = resolve
|
|
141
|
+
? resolve(rule)
|
|
142
|
+
: { critical: rule.critical === true, source: 'default' };
|
|
143
|
+
out.push({
|
|
144
|
+
name: rule.name,
|
|
145
|
+
category,
|
|
146
|
+
description: rule.description,
|
|
147
|
+
weight: rule.weight,
|
|
148
|
+
critical: effective.critical,
|
|
149
|
+
criticalSource: effective.source,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
package/dist/eval/engine.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EvalRule, EvalContext, EvalResult, EvalResultType, EvalType, CustomRuleDefinition } from '../types/eval.js';
|
|
2
|
+
import { type CriticalityOverrides, type EffectiveCriticality } from './criticality.js';
|
|
2
3
|
/**
|
|
3
4
|
* Every bundle eval_type="all" walks, in the order their categories are
|
|
4
5
|
* reported. 'custom' is last: it holds only deployed rules registered under
|
|
@@ -38,7 +39,21 @@ export declare class EvalEngine {
|
|
|
38
39
|
private idByRule;
|
|
39
40
|
private threshold;
|
|
40
41
|
private ruleThresholds?;
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Effective criticality per rule, bound to this engine's config overrides.
|
|
44
|
+
* Every veto decision reads THIS, never `rule.critical` directly, so a
|
|
45
|
+
* promotion or demotion cannot apply on one code path and not another.
|
|
46
|
+
*/
|
|
47
|
+
private criticality;
|
|
48
|
+
/**
|
|
49
|
+
* `criticalityOverrides` are `config.eval` — the criticalRules /
|
|
50
|
+
* nonCriticalRules lists. Validated here as well as in loadConfig, so an
|
|
51
|
+
* engine built directly (a test, an embedder) cannot silently ignore a
|
|
52
|
+
* misspelled rule name.
|
|
53
|
+
*/
|
|
54
|
+
constructor(threshold?: number, ruleThresholds?: Record<string, unknown>, criticalityOverrides?: CriticalityOverrides);
|
|
55
|
+
/** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
|
|
56
|
+
effectiveCriticality(rule: EvalRule): EffectiveCriticality;
|
|
42
57
|
/**
|
|
43
58
|
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
44
59
|
* is IDEMPOTENT by id: registering an id that is already live replaces the
|
package/dist/eval/engine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRulesForType, createCustomRule } from './rules/index.js';
|
|
2
|
+
import { criticalityResolver } from './criticality.js';
|
|
2
3
|
import { generateEvalId } from '../utils/ids.js';
|
|
3
4
|
/**
|
|
4
5
|
* Every bundle eval_type="all" walks, in the order their categories are
|
|
@@ -39,9 +40,26 @@ export class EvalEngine {
|
|
|
39
40
|
idByRule = new Map();
|
|
40
41
|
threshold;
|
|
41
42
|
ruleThresholds;
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Effective criticality per rule, bound to this engine's config overrides.
|
|
45
|
+
* Every veto decision reads THIS, never `rule.critical` directly, so a
|
|
46
|
+
* promotion or demotion cannot apply on one code path and not another.
|
|
47
|
+
*/
|
|
48
|
+
criticality;
|
|
49
|
+
/**
|
|
50
|
+
* `criticalityOverrides` are `config.eval` — the criticalRules /
|
|
51
|
+
* nonCriticalRules lists. Validated here as well as in loadConfig, so an
|
|
52
|
+
* engine built directly (a test, an embedder) cannot silently ignore a
|
|
53
|
+
* misspelled rule name.
|
|
54
|
+
*/
|
|
55
|
+
constructor(threshold = 0.7, ruleThresholds, criticalityOverrides) {
|
|
43
56
|
this.threshold = threshold;
|
|
44
57
|
this.ruleThresholds = ruleThresholds;
|
|
58
|
+
this.criticality = criticalityResolver(criticalityOverrides);
|
|
59
|
+
}
|
|
60
|
+
/** The effective criticality of one rule under this engine's config. Read by the rule roster surfaces. */
|
|
61
|
+
effectiveCriticality(rule) {
|
|
62
|
+
return this.criticality(rule);
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
65
|
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
@@ -174,8 +192,16 @@ export class EvalEngine {
|
|
|
174
192
|
const raw = rule.evaluate(evalContext);
|
|
175
193
|
const ruleId = this.idByRule.get(rule);
|
|
176
194
|
const category = categories?.[i];
|
|
177
|
-
|
|
178
|
-
|
|
195
|
+
/*
|
|
196
|
+
* Every result says whether THIS rule vetoes and who decided that.
|
|
197
|
+
* Without it, a reader holding a failed evaluation cannot tell a
|
|
198
|
+
* hard violation from a merely low score without knowing the rule
|
|
199
|
+
* library by heart — and once eval.criticalRules exists, cannot tell
|
|
200
|
+
* a shipped default from their own promotion at all. Stamped here, on
|
|
201
|
+
* the one path every evaluation takes, so a surface cannot render the
|
|
202
|
+
* declared criticality where the engine applied a configured one.
|
|
203
|
+
*/
|
|
204
|
+
const { critical, source } = this.criticality(rule);
|
|
179
205
|
// ruleId / category sit right after the name so a reader scanning
|
|
180
206
|
// rule_results sees WHICH deployed rule (and which bundle) spoke.
|
|
181
207
|
const { ruleName, ...rest } = raw;
|
|
@@ -183,6 +209,8 @@ export class EvalEngine {
|
|
|
183
209
|
ruleName,
|
|
184
210
|
...(ruleId !== undefined ? { ruleId } : {}),
|
|
185
211
|
...(category !== undefined ? { category } : {}),
|
|
212
|
+
critical,
|
|
213
|
+
criticalSource: source,
|
|
186
214
|
...rest,
|
|
187
215
|
};
|
|
188
216
|
});
|
|
@@ -301,7 +329,7 @@ export class EvalEngine {
|
|
|
301
329
|
}
|
|
302
330
|
}
|
|
303
331
|
const criticalSkipped = skippedIndices
|
|
304
|
-
.filter((i) => rules[i].critical
|
|
332
|
+
.filter((i) => this.criticality(rules[i]).critical)
|
|
305
333
|
.map((i) => ruleResults[i].ruleName);
|
|
306
334
|
if (evaluatedIndices.length === 0) {
|
|
307
335
|
return {
|
|
@@ -322,7 +350,7 @@ export class EvalEngine {
|
|
|
322
350
|
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
323
351
|
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
324
352
|
const criticalFailures = evaluatedIndices
|
|
325
|
-
.filter((i) => rules[i].critical
|
|
353
|
+
.filter((i) => this.criticality(rules[i]).critical && !ruleResults[i].passed)
|
|
326
354
|
.map((i) => ruleResults[i].ruleName);
|
|
327
355
|
return {
|
|
328
356
|
score,
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { EvalRule } from '../../types/eval.js';
|
|
2
2
|
export declare const costUnderThreshold: EvalRule;
|
|
3
3
|
export declare const tokenEfficiency: EvalRule;
|
|
4
|
+
/** Default for config key `max_tool_repeats`: how many identical calls are tolerated. */
|
|
5
|
+
export declare const DEFAULT_MAX_TOOL_REPEATS = 3;
|
|
6
|
+
/**
|
|
7
|
+
* How many complete A,B,A,B cycles are tolerated before the alternation is
|
|
8
|
+
* a loop. Three cycles is six consecutive calls that between them made two
|
|
9
|
+
* distinct requests.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_TWO_CALL_CYCLES = 2;
|
|
12
|
+
export declare const noToolLoop: EvalRule;
|
|
4
13
|
export declare const costRules: EvalRule[];
|
package/dist/eval/rules/cost.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { callKey, describeInput, skipWithoutTrajectory } from './trajectory.js';
|
|
1
2
|
export const costUnderThreshold = {
|
|
2
3
|
name: 'cost_under_threshold',
|
|
3
4
|
description: 'Total cost must be under a configurable USD threshold',
|
|
@@ -44,4 +45,99 @@ export const tokenEfficiency = {
|
|
|
44
45
|
};
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
|
-
|
|
48
|
+
/** Default for config key `max_tool_repeats`: how many identical calls are tolerated. */
|
|
49
|
+
export const DEFAULT_MAX_TOOL_REPEATS = 3;
|
|
50
|
+
/**
|
|
51
|
+
* How many complete A,B,A,B cycles are tolerated before the alternation is
|
|
52
|
+
* a loop. Three cycles is six consecutive calls that between them made two
|
|
53
|
+
* distinct requests.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_TWO_CALL_CYCLES = 2;
|
|
56
|
+
/** The longest run of alternating A,B calls, and how many complete cycles it holds. */
|
|
57
|
+
function longestTwoCallCycle(keys) {
|
|
58
|
+
let best = null;
|
|
59
|
+
for (let start = 0; start + 3 < keys.length; start++) {
|
|
60
|
+
const a = keys[start];
|
|
61
|
+
const b = keys[start + 1];
|
|
62
|
+
if (a === b)
|
|
63
|
+
continue;
|
|
64
|
+
let len = 2;
|
|
65
|
+
while (start + len < keys.length && keys[start + len] === (len % 2 === 0 ? a : b))
|
|
66
|
+
len++;
|
|
67
|
+
const cycles = Math.floor(len / 2);
|
|
68
|
+
if (cycles >= 2 && (best === null || cycles > best.cycles))
|
|
69
|
+
best = { a, b, cycles };
|
|
70
|
+
}
|
|
71
|
+
return best;
|
|
72
|
+
}
|
|
73
|
+
/*
|
|
74
|
+
* The other half of what the trajectory shows: not a wrong answer, a wasted
|
|
75
|
+
* one.
|
|
76
|
+
*
|
|
77
|
+
* Transcript t-16 answers the question correctly, and gets there by running
|
|
78
|
+
* the identical `ls src/tools` five times with five identical results. Four
|
|
79
|
+
* of those turns bought nothing, and each one resent the whole context —
|
|
80
|
+
* 18,918 prompt tokens for a directory listing. cost_under_threshold cannot
|
|
81
|
+
* see it: the bill still comes to $0.0621, well under the $0.10 default. The
|
|
82
|
+
* loop is only visible in the sequence of calls.
|
|
83
|
+
*
|
|
84
|
+
* Cost, not completeness or safety, because that is where the harm lands —
|
|
85
|
+
* spend and latency, on an answer that was already available. Non-critical
|
|
86
|
+
* for the same reason: a repetitive agent is wasteful, not unsafe, and
|
|
87
|
+
* `passed` should not be vetoed by a behavioural signal.
|
|
88
|
+
*/
|
|
89
|
+
export const noToolLoop = {
|
|
90
|
+
name: 'no_tool_loop',
|
|
91
|
+
description: 'The agent must not repeat itself. Fails when one tool is called with an identical input (object keys sorted, whitespace collapsed) more than max_tool_repeats times — default 3, config key `max_tool_repeats` — or when two calls alternate for more than two complete A,B,A,B cycles. Skips when no tool calls are provided, so an evaluation with no trajectory reports "not judged" rather than clean. Catches the wasted spend a cost threshold cannot see: five identical calls can still bill under a per-evaluation cost limit',
|
|
92
|
+
evalType: 'cost',
|
|
93
|
+
weight: 1,
|
|
94
|
+
evaluate(context) {
|
|
95
|
+
const skip = skipWithoutTrajectory('no_tool_loop', context);
|
|
96
|
+
if (skip)
|
|
97
|
+
return skip;
|
|
98
|
+
const calls = context.toolCalls ?? [];
|
|
99
|
+
const configured = context.customConfig?.max_tool_repeats;
|
|
100
|
+
const maxRepeats = typeof configured === 'number' && Number.isFinite(configured) && configured >= 1
|
|
101
|
+
? Math.floor(configured)
|
|
102
|
+
: DEFAULT_MAX_TOOL_REPEATS;
|
|
103
|
+
const keys = calls.map(callKey);
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const key of keys)
|
|
106
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
107
|
+
let worstKey = '';
|
|
108
|
+
let worstCount = 0;
|
|
109
|
+
for (const [key, count] of counts) {
|
|
110
|
+
if (count > worstCount) {
|
|
111
|
+
worstKey = key;
|
|
112
|
+
worstCount = count;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (worstCount > maxRepeats) {
|
|
116
|
+
const call = calls[keys.indexOf(worstKey)];
|
|
117
|
+
return {
|
|
118
|
+
ruleName: 'no_tool_loop',
|
|
119
|
+
passed: false,
|
|
120
|
+
score: Math.max(0, 1 - (worstCount - maxRepeats) * 0.25),
|
|
121
|
+
message: `Tool loop: ${call.tool_name} called ${worstCount} times with the same input — ${describeInput(call.input)} — over ${calls.length} call${calls.length === 1 ? '' : 's'} (max ${maxRepeats})`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const cycle = longestTwoCallCycle(keys);
|
|
125
|
+
if (cycle !== null && cycle.cycles > MAX_TWO_CALL_CYCLES) {
|
|
126
|
+
const a = calls[keys.indexOf(cycle.a)];
|
|
127
|
+
const b = calls[keys.indexOf(cycle.b)];
|
|
128
|
+
return {
|
|
129
|
+
ruleName: 'no_tool_loop',
|
|
130
|
+
passed: false,
|
|
131
|
+
score: Math.max(0, 1 - (cycle.cycles - MAX_TWO_CALL_CYCLES) * 0.25),
|
|
132
|
+
message: `Tool loop: ${a.tool_name} (${describeInput(a.input)}) and ${b.tool_name} (${describeInput(b.input)}) alternate for ${cycle.cycles} cycles (max ${MAX_TWO_CALL_CYCLES})`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ruleName: 'no_tool_loop',
|
|
137
|
+
passed: true,
|
|
138
|
+
score: 1,
|
|
139
|
+
message: `No repeated tool call (${calls.length} call${calls.length === 1 ? '' : 's'}; most repeated ran ${worstCount}×, max ${maxRepeats})`,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
export const costRules = [costUnderThreshold, tokenEfficiency, noToolLoop];
|
|
@@ -37,4 +37,5 @@ export interface HallucinationSignal {
|
|
|
37
37
|
}
|
|
38
38
|
export declare const HALLUCINATION_MARKERS: ReadonlyArray<HallucinationSignal>;
|
|
39
39
|
export declare const noHallucinationMarkers: EvalRule;
|
|
40
|
+
export declare const noSilentToolFailure: EvalRule;
|
|
40
41
|
export declare const safetyRules: EvalRule[];
|