@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.
Files changed (38) hide show
  1. package/README.md +7 -3
  2. package/dist/config/defaults.js +17 -1
  3. package/dist/config/index.js +8 -0
  4. package/dist/dashboard/assets/{index-CshLgDRB.js → index-DTA8DzF_.js} +1 -1
  5. package/dist/dashboard/index.html +1 -1
  6. package/dist/dashboard/routes/rules.d.ts +8 -11
  7. package/dist/dashboard/routes/rules.js +9 -16
  8. package/dist/dashboard/routes/traces.js +14 -2
  9. package/dist/dashboard/validation.d.ts +4 -4
  10. package/dist/dashboard/validation.js +6 -2
  11. package/dist/eval/criticality.d.ts +67 -0
  12. package/dist/eval/criticality.js +154 -0
  13. package/dist/eval/engine.d.ts +33 -2
  14. package/dist/eval/engine.js +64 -8
  15. package/dist/eval/rules/cost.d.ts +9 -0
  16. package/dist/eval/rules/cost.js +97 -1
  17. package/dist/eval/rules/relevance.d.ts +13 -0
  18. package/dist/eval/rules/relevance.js +185 -21
  19. package/dist/eval/rules/safety.d.ts +13 -1
  20. package/dist/eval/rules/safety.js +273 -21
  21. package/dist/eval/rules/trajectory.d.ts +91 -0
  22. package/dist/eval/rules/trajectory.js +297 -0
  23. package/dist/index.js +1 -1
  24. package/dist/self-test.js +1 -1
  25. package/dist/server.js +1 -1
  26. package/dist/tools/evaluate-output.js +38 -23
  27. package/dist/tools/index.js +1 -1
  28. package/dist/tools/list-rules.d.ts +2 -1
  29. package/dist/tools/list-rules.js +22 -4
  30. package/dist/tools/log-trace.d.ts +8 -1
  31. package/dist/tools/log-trace.js +19 -4
  32. package/dist/tools/strict-input.js +2 -2
  33. package/dist/tools/trace-link.d.ts +10 -0
  34. package/dist/tools/trace-link.js +13 -1
  35. package/dist/types/config.d.ts +14 -0
  36. package/dist/types/eval.d.ts +39 -7
  37. package/package.json +8 -1
  38. 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-CshLgDRB.js"></script>
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 { EvalType } from '../../types/eval.js';
5
+ import { type BuiltInRuleMeta } from '../../eval/criticality.js';
6
6
  interface RoutesOptions {
7
7
  customRuleStore: CustomRuleStore;
8
8
  evalEngine: EvalEngine;
9
9
  }
10
- /** Built-in rule metadata as the dashboard sees it — derived from the engine, never restated. */
11
- export interface BuiltInRuleMeta {
12
- name: string;
13
- category: EvalType;
14
- description: string;
15
- weight: number;
16
- critical: boolean;
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 { rulesByType } from '../../eval/rules/index.js';
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
- export function listBuiltInRules() {
81
- const out = [];
82
- for (const [category, rules] of Object.entries(rulesByType)) {
83
- for (const rule of rules) {
84
- out.push({
85
- name: rule.name,
86
- category,
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
- const evaluation = body.eval_type === 'all'
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(body.eval_type, context);
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.ZodDefault<z.ZodEnum<{
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.$strip>>>;
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
- }>, z.ZodTransform<boolean, "true" | "false">>>;
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
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).default('completeness'),
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
+ }
@@ -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
- constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
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
@@ -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
- constructor(threshold = 0.7, ruleThresholds) {
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
- if (ruleId === undefined && category === undefined)
162
- return raw;
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 === true)
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 === true && !ruleResults[i].passed)
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: verdict.rulesEvaluated === 0,
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[];