@iris-eval/mcp-server 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/config/defaults.js +17 -1
- package/dist/config/index.js +8 -0
- package/dist/dashboard/assets/{index-CshLgDRB.js → index-DTA8DzF_.js} +1 -1
- 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 +14 -2
- package/dist/dashboard/validation.d.ts +4 -4
- package/dist/dashboard/validation.js +6 -2
- package/dist/eval/criticality.d.ts +67 -0
- package/dist/eval/criticality.js +154 -0
- package/dist/eval/engine.d.ts +33 -2
- package/dist/eval/engine.js +64 -8
- package/dist/eval/rules/cost.d.ts +9 -0
- package/dist/eval/rules/cost.js +97 -1
- package/dist/eval/rules/relevance.d.ts +13 -0
- package/dist/eval/rules/relevance.js +185 -21
- package/dist/eval/rules/safety.d.ts +13 -1
- package/dist/eval/rules/safety.js +273 -21
- 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 +38 -23
- 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/strict-input.js +2 -2
- 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 +39 -7
- package/package.json +8 -1
- package/server.json +2 -2
|
@@ -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-DTA8DzF_.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);
|
|
@@ -2,6 +2,7 @@ import { requireTenant } from '../../middleware/tenant.js';
|
|
|
2
2
|
import { generateTraceId, generateSpanId } from '../../utils/ids.js';
|
|
3
3
|
import { bestEffortExport } from '../../otel/lazy.js';
|
|
4
4
|
import { traceQuerySchema, ingestTraceSchema } from '../validation.js';
|
|
5
|
+
import { DEFAULT_EVAL_TYPE, DEFAULT_EVAL_TYPE_NOTE } from '../../eval/engine.js';
|
|
5
6
|
export function registerTraceRoutes(router, storage, options) {
|
|
6
7
|
/*
|
|
7
8
|
* Deterministic capture over HTTP. MCP tool calls are model-
|
|
@@ -67,10 +68,20 @@ export function registerTraceRoutes(router, storage, options) {
|
|
|
67
68
|
input: body.input,
|
|
68
69
|
costUsd: body.cost_usd,
|
|
69
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,
|
|
70
77
|
};
|
|
71
|
-
|
|
78
|
+
// An omitted eval_type runs every bundle — the same default, from the
|
|
79
|
+
// same constant, as the MCP tool — and says so in the response.
|
|
80
|
+
const evalTypeOmitted = body.eval_type === undefined;
|
|
81
|
+
const evalType = body.eval_type ?? DEFAULT_EVAL_TYPE;
|
|
82
|
+
const evaluation = evalType === 'all'
|
|
72
83
|
? options.evalEngine.evaluateAll(context)
|
|
73
|
-
: options.evalEngine.evaluate(
|
|
84
|
+
: options.evalEngine.evaluate(evalType, context);
|
|
74
85
|
evaluation.trace_id = traceId;
|
|
75
86
|
await storage.insertEvalResult(tenantId, evaluation);
|
|
76
87
|
res.status(201).json({
|
|
@@ -103,6 +114,7 @@ export function registerTraceRoutes(router, storage, options) {
|
|
|
103
114
|
: {}),
|
|
104
115
|
// Per-bundle breakdown — eval_type="all" only.
|
|
105
116
|
...(evaluation.categories ? { categories: evaluation.categories } : {}),
|
|
117
|
+
...(evalTypeOmitted ? { note: DEFAULT_EVAL_TYPE_NOTE } : {}),
|
|
106
118
|
},
|
|
107
119
|
});
|
|
108
120
|
}
|
|
@@ -4,7 +4,7 @@ export declare function strictBody<T extends z.ZodRawShape>(shape: T, opts?: {
|
|
|
4
4
|
}): z.ZodObject<{ -readonly [P in keyof T]: T[P]; }, z.core.$strict>;
|
|
5
5
|
export declare const ingestTraceSchema: z.ZodObject<{
|
|
6
6
|
evaluate: z.ZodDefault<z.ZodBoolean>;
|
|
7
|
-
eval_type: z.
|
|
7
|
+
eval_type: z.ZodOptional<z.ZodEnum<{
|
|
8
8
|
completeness: "completeness";
|
|
9
9
|
relevance: "relevance";
|
|
10
10
|
safety: "safety";
|
|
@@ -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>>;
|
|
@@ -52,8 +52,12 @@ export const ingestTraceSchema = strictBody({
|
|
|
52
52
|
// Same bundle list evaluate_output accepts, "all" included — the
|
|
53
53
|
// ingest path used to stop one short and run the single-bundle engine
|
|
54
54
|
// no matter what, so an HTTP caller could not get the per-category
|
|
55
|
-
// verdict the MCP tool returns.
|
|
56
|
-
|
|
55
|
+
// verdict the MCP tool returns. Optional rather than defaulted HERE so
|
|
56
|
+
// the route can tell "chose all" from "never chose": the effective
|
|
57
|
+
// default is every bundle (DEFAULT_EVAL_TYPE in eval/engine.ts, the
|
|
58
|
+
// same constant evaluate_output reads), and an omitted eval_type gets a
|
|
59
|
+
// note in the response saying the default ran.
|
|
60
|
+
eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).optional(),
|
|
57
61
|
}, {
|
|
58
62
|
reserved: {
|
|
59
63
|
trace_id: 'trace_id is minted by the server on every ingest and cannot be supplied by the client — ' +
|
|
@@ -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
|
-
import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition } from '../types/eval.js';
|
|
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
|
|
@@ -6,6 +7,22 @@ import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition
|
|
|
6
7
|
* from the breakdown when neither exists.
|
|
7
8
|
*/
|
|
8
9
|
export declare const ALL_EVAL_TYPES: readonly EvalType[];
|
|
10
|
+
/**
|
|
11
|
+
* What runs when a caller never chose a bundle. It used to be
|
|
12
|
+
* 'completeness', so a CI gate keyed on `passed` skipped PII and injection
|
|
13
|
+
* unless the caller knew to set eval_type — six of seven UAT personas read
|
|
14
|
+
* passed:true on PII-laden text with nothing in the payload saying the
|
|
15
|
+
* safety bundle had not run. Every bundle is the only default under which
|
|
16
|
+
* an omitted argument cannot silently narrow the verdict. The MCP tool and
|
|
17
|
+
* the HTTP ingest route both read this constant, so the two surfaces
|
|
18
|
+
* cannot default differently.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_EVAL_TYPE: EvalResultType;
|
|
21
|
+
/**
|
|
22
|
+
* The one-line note both surfaces attach when the default ran, so a reader
|
|
23
|
+
* of the response knows the bundle was chosen for them and how to narrow it.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_EVAL_TYPE_NOTE = "eval_type was omitted, so the default ran every bundle \u2014 completeness, relevance, safety, cost and any custom rules \u2014 the same as eval_type=\"all\"; pass a single bundle name to narrow the run.";
|
|
9
26
|
export declare class EvalEngine {
|
|
10
27
|
private additionalRules;
|
|
11
28
|
/**
|
|
@@ -22,7 +39,21 @@ export declare class EvalEngine {
|
|
|
22
39
|
private idByRule;
|
|
23
40
|
private threshold;
|
|
24
41
|
private ruleThresholds?;
|
|
25
|
-
|
|
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;
|
|
26
57
|
/**
|
|
27
58
|
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
28
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
|
|
@@ -7,6 +8,22 @@ import { generateEvalId } from '../utils/ids.js';
|
|
|
7
8
|
* from the breakdown when neither exists.
|
|
8
9
|
*/
|
|
9
10
|
export const ALL_EVAL_TYPES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
|
|
11
|
+
/**
|
|
12
|
+
* What runs when a caller never chose a bundle. It used to be
|
|
13
|
+
* 'completeness', so a CI gate keyed on `passed` skipped PII and injection
|
|
14
|
+
* unless the caller knew to set eval_type — six of seven UAT personas read
|
|
15
|
+
* passed:true on PII-laden text with nothing in the payload saying the
|
|
16
|
+
* safety bundle had not run. Every bundle is the only default under which
|
|
17
|
+
* an omitted argument cannot silently narrow the verdict. The MCP tool and
|
|
18
|
+
* the HTTP ingest route both read this constant, so the two surfaces
|
|
19
|
+
* cannot default differently.
|
|
20
|
+
*/
|
|
21
|
+
export const DEFAULT_EVAL_TYPE = 'all';
|
|
22
|
+
/**
|
|
23
|
+
* The one-line note both surfaces attach when the default ran, so a reader
|
|
24
|
+
* of the response knows the bundle was chosen for them and how to narrow it.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_EVAL_TYPE_NOTE = 'eval_type was omitted, so the default ran every bundle — completeness, relevance, safety, cost and any custom rules — the same as eval_type="all"; pass a single bundle name to narrow the run.';
|
|
10
27
|
export class EvalEngine {
|
|
11
28
|
additionalRules = new Map();
|
|
12
29
|
/**
|
|
@@ -23,9 +40,26 @@ export class EvalEngine {
|
|
|
23
40
|
idByRule = new Map();
|
|
24
41
|
threshold;
|
|
25
42
|
ruleThresholds;
|
|
26
|
-
|
|
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) {
|
|
27
56
|
this.threshold = threshold;
|
|
28
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);
|
|
29
63
|
}
|
|
30
64
|
/**
|
|
31
65
|
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
@@ -158,8 +192,16 @@ export class EvalEngine {
|
|
|
158
192
|
const raw = rule.evaluate(evalContext);
|
|
159
193
|
const ruleId = this.idByRule.get(rule);
|
|
160
194
|
const category = categories?.[i];
|
|
161
|
-
|
|
162
|
-
|
|
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);
|
|
163
205
|
// ruleId / category sit right after the name so a reader scanning
|
|
164
206
|
// rule_results sees WHICH deployed rule (and which bundle) spoke.
|
|
165
207
|
const { ruleName, ...rest } = raw;
|
|
@@ -167,6 +209,8 @@ export class EvalEngine {
|
|
|
167
209
|
ruleName,
|
|
168
210
|
...(ruleId !== undefined ? { ruleId } : {}),
|
|
169
211
|
...(category !== undefined ? { category } : {}),
|
|
212
|
+
critical,
|
|
213
|
+
criticalSource: source,
|
|
170
214
|
...rest,
|
|
171
215
|
};
|
|
172
216
|
});
|
|
@@ -285,7 +329,7 @@ export class EvalEngine {
|
|
|
285
329
|
}
|
|
286
330
|
}
|
|
287
331
|
const criticalSkipped = skippedIndices
|
|
288
|
-
.filter((i) => rules[i].critical
|
|
332
|
+
.filter((i) => this.criticality(rules[i]).critical)
|
|
289
333
|
.map((i) => ruleResults[i].ruleName);
|
|
290
334
|
if (evaluatedIndices.length === 0) {
|
|
291
335
|
return {
|
|
@@ -306,7 +350,7 @@ export class EvalEngine {
|
|
|
306
350
|
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
307
351
|
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
308
352
|
const criticalFailures = evaluatedIndices
|
|
309
|
-
.filter((i) => rules[i].critical
|
|
353
|
+
.filter((i) => this.criticality(rules[i]).critical && !ruleResults[i].passed)
|
|
310
354
|
.map((i) => ruleResults[i].ruleName);
|
|
311
355
|
return {
|
|
312
356
|
score,
|
|
@@ -325,12 +369,24 @@ export class EvalEngine {
|
|
|
325
369
|
if (indices.length === 0)
|
|
326
370
|
continue;
|
|
327
371
|
const verdict = this.summarize(indices.map((i) => rules[i]), indices.map((i) => ruleResults[i]));
|
|
372
|
+
/*
|
|
373
|
+
* A bundle whose every rule skipped was not judged (#406). Reporting
|
|
374
|
+
* it as passed:false / score:0 read as "failing" to anyone regrouping
|
|
375
|
+
* by category — cost "failed" on a call that carried no cost data.
|
|
376
|
+
* Inside the breakdown, null is the honest value: neither passing
|
|
377
|
+
* nor failing, and it never counted toward the overall verdict
|
|
378
|
+
* (summarize() already excludes skipped rules). The TOP-LEVEL
|
|
379
|
+
* `passed` is deliberately not made nullable — it is the verdict a
|
|
380
|
+
* gate keys on, and a gate must fail closed when nothing was judged;
|
|
381
|
+
* `insufficient_data: true` is the "unknown" marker at that level.
|
|
382
|
+
*/
|
|
383
|
+
const judged = verdict.rulesEvaluated > 0;
|
|
328
384
|
breakdown[type] = {
|
|
329
|
-
score: Math.round(verdict.score * 1000) / 1000,
|
|
330
|
-
passed: verdict.passed,
|
|
385
|
+
score: judged ? Math.round(verdict.score * 1000) / 1000 : null,
|
|
386
|
+
passed: judged ? verdict.passed : null,
|
|
331
387
|
rules_evaluated: verdict.rulesEvaluated,
|
|
332
388
|
rules_skipped: verdict.rulesSkipped,
|
|
333
|
-
insufficient_data:
|
|
389
|
+
insufficient_data: !judged,
|
|
334
390
|
...(verdict.criticalFailures.length > 0 ? { critical_failures: verdict.criticalFailures } : {}),
|
|
335
391
|
...(verdict.criticalSkipped.length > 0 ? { critical_skipped: verdict.criticalSkipped } : {}),
|
|
336
392
|
};
|
|
@@ -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[];
|