@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.
@@ -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-CKs2Wbd_.js"></script>
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 { 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);
@@ -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.$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>>;
@@ -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
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
- 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;
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
@@ -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
- 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) {
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
- if (ruleId === undefined && category === undefined)
178
- 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);
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 === true)
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 === true && !ruleResults[i].passed)
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[];
@@ -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
- export const costRules = [costUnderThreshold, tokenEfficiency];
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[];