@iris-eval/mcp-server 0.5.0 → 0.6.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 (65) hide show
  1. package/README.md +99 -36
  2. package/dist/config/index.d.ts +10 -0
  3. package/dist/config/index.js +33 -7
  4. package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
  5. package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
  6. package/dist/dashboard/index.html +4 -3
  7. package/dist/dashboard/routes/health.js +10 -3
  8. package/dist/dashboard/routes/moments.js +1 -1
  9. package/dist/dashboard/routes/preferences.d.ts +1 -0
  10. package/dist/dashboard/routes/preferences.js +31 -3
  11. package/dist/dashboard/routes/rules.d.ts +18 -0
  12. package/dist/dashboard/routes/rules.js +160 -6
  13. package/dist/dashboard/routes/traces.js +30 -3
  14. package/dist/dashboard/seed-demo-data.js +14 -3
  15. package/dist/dashboard/server.js +13 -3
  16. package/dist/dashboard/session-auth.d.ts +8 -0
  17. package/dist/dashboard/session-auth.js +237 -0
  18. package/dist/dashboard/validation.d.ts +9 -3
  19. package/dist/dashboard/validation.js +69 -11
  20. package/dist/eval/citation-verify/verifier.d.ts +17 -0
  21. package/dist/eval/citation-verify/verifier.js +68 -15
  22. package/dist/eval/decision-moment.js +17 -9
  23. package/dist/eval/engine.d.ts +62 -0
  24. package/dist/eval/engine.js +196 -58
  25. package/dist/eval/llm-judge/evaluator.js +50 -33
  26. package/dist/eval/llm-judge/templates/index.d.ts +4 -0
  27. package/dist/eval/llm-judge/templates/index.js +10 -4
  28. package/dist/eval/rules/custom.js +59 -6
  29. package/dist/eval/rules/relevance.js +1 -1
  30. package/dist/eval/rules/safety.d.ts +8 -0
  31. package/dist/eval/rules/safety.js +63 -18
  32. package/dist/index.js +102 -16
  33. package/dist/middleware/rate-limit.d.ts +25 -0
  34. package/dist/middleware/rate-limit.js +54 -2
  35. package/dist/self-test.d.ts +14 -0
  36. package/dist/self-test.js +97 -13
  37. package/dist/storage/demo-guard.d.ts +8 -0
  38. package/dist/storage/demo-guard.js +53 -0
  39. package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
  40. package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
  41. package/dist/storage/migrations/index.js +2 -0
  42. package/dist/storage/sqlite-adapter.d.ts +6 -0
  43. package/dist/storage/sqlite-adapter.js +91 -4
  44. package/dist/tools/delete-rule.js +49 -11
  45. package/dist/tools/deploy-rule.d.ts +33 -0
  46. package/dist/tools/deploy-rule.js +130 -27
  47. package/dist/tools/evaluate-output.js +50 -24
  48. package/dist/tools/evaluate-with-llm-judge.js +11 -4
  49. package/dist/tools/get-traces.d.ts +27 -0
  50. package/dist/tools/get-traces.js +60 -8
  51. package/dist/tools/list-rules.js +2 -2
  52. package/dist/tools/log-trace.js +5 -4
  53. package/dist/tools/strict-input.d.ts +1 -0
  54. package/dist/tools/strict-input.js +25 -0
  55. package/dist/tools/trace-link.d.ts +7 -0
  56. package/dist/tools/trace-link.js +39 -0
  57. package/dist/tools/verify-citations.d.ts +19 -0
  58. package/dist/tools/verify-citations.js +42 -5
  59. package/dist/types/decision-moment.d.ts +8 -0
  60. package/dist/types/eval.d.ts +60 -1
  61. package/dist/types/index.d.ts +1 -1
  62. package/dist/types/query.d.ts +25 -0
  63. package/package.json +1 -1
  64. package/server.json +2 -2
  65. package/dist/dashboard/assets/index-BZZt8bVh.js +0 -10
@@ -7,16 +7,82 @@
7
7
  *
8
8
  * Writes to ~/.iris/custom-rules.json (single source of truth) and
9
9
  * appends to the audit log. Persisted rules auto-load on server boot
10
- * and fire on every future evaluate_output call of the matching
11
- * eval_type.
10
+ * and fire on every future evaluate_output call whose eval_type equals
11
+ * the rule's evalType (or eval_type="all").
12
12
  */
13
13
  import { z } from 'zod';
14
14
  import { createCustomRule } from '../eval/rules/custom.js';
15
15
  import { LOCAL_TENANT } from '../types/tenant.js';
16
- import { strictInput } from './strict-input.js';
17
- const CustomRuleDefinitionSchema = z.object({
18
- name: z.string(),
19
- type: z.enum([
16
+ import { strictInput, strictNested } from './strict-input.js';
17
+ const EvalTypeSchema = z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']);
18
+ /**
19
+ * A rule with this name is already deployed and the caller did not ask to
20
+ * replace it. Carries the existing rule(s) so an HTTP surface can answer
21
+ * 409 with them beside the same message the MCP tool throws.
22
+ */
23
+ export class DuplicateRuleNameError extends Error {
24
+ existing;
25
+ constructor(name, existing) {
26
+ const listed = existing
27
+ .map((r) => `${r.id} (eval_type ${r.evalType}, severity ${r.severity}, ${r.enabled ? 'enabled' : 'disabled'})`)
28
+ .join('; ');
29
+ super(`A rule named "${name}" is already deployed: ${listed}. Deploying a second rule with the same name ` +
30
+ 'would make both fire with indistinguishable rule_results. Pass replace: true to delete the existing rule ' +
31
+ 'and deploy this one in its place, call delete_rule first, or choose a different name. Nothing was deployed.');
32
+ this.name = 'DuplicateRuleNameError';
33
+ this.existing = existing;
34
+ }
35
+ }
36
+ /**
37
+ * Same-name redeploy (#373). Two rules with one name both fire, and their
38
+ * rule_results used to be indistinguishable — the same ruleName showing
39
+ * PASS and FAIL in one response. Refuse by default; with replace:true,
40
+ * retire the earlier rule(s) first so the name means one thing again. The
41
+ * store keeps the audit trail either way.
42
+ *
43
+ * One function for both deploy surfaces — the `deploy_rule` tool and the
44
+ * dashboard's `POST /api/v1/rules/custom` — so the semantics and the
45
+ * wording cannot drift between them. Returns the rules it retired (empty
46
+ * when the name was free); throws DuplicateRuleNameError when the name is
47
+ * taken and `replace` is false. Nothing is deployed by this function.
48
+ */
49
+ export function retireSameNamedRules(store, engine, tenantId, name, replace, user) {
50
+ const sameName = store.list(tenantId).filter((r) => r.name === name);
51
+ if (sameName.length === 0)
52
+ return [];
53
+ if (!replace)
54
+ throw new DuplicateRuleNameError(name, sameName);
55
+ const replaced = [];
56
+ for (const old of sameName) {
57
+ if (store.delete(tenantId, old.id, user)) {
58
+ engine.unregisterRule(old.id);
59
+ replaced.push({ id: old.id, evalType: old.evalType, severity: old.severity });
60
+ }
61
+ }
62
+ return replaced;
63
+ }
64
+ /** The `warning` both deploy surfaces attach when a replace retired rules. */
65
+ export function replacedRulesWarning(name, replaced) {
66
+ return (`Replaced ${replaced.length} previously deployed rule(s) named "${name}" ` +
67
+ `(${replaced.map((r) => r.id).join(', ')}); they no longer fire. Their audit rows are preserved.`);
68
+ }
69
+ /*
70
+ * Strict one level down, like evaluate_output's custom_rules entries
71
+ * (#376): a misspelled `wieght` used to be dropped silently. `config`
72
+ * stays free-form (its keys depend on `type` and are validated by the
73
+ * store at deploy time). `name` is optional: the server always overwrites
74
+ * it with the top-level rule name (#377), so requiring a value that is
75
+ * then discarded only invited a mismatch.
76
+ */
77
+ const CustomRuleDefinitionSchema = strictNested({
78
+ name: z
79
+ .string()
80
+ .min(1)
81
+ .max(80)
82
+ .optional()
83
+ .describe('Optional and IGNORED if given — the server overwrites it with the top-level `name` so the rule reports under one name everywhere'),
84
+ type: z
85
+ .enum([
20
86
  'regex_match',
21
87
  'regex_no_match',
22
88
  'min_length',
@@ -25,55 +91,81 @@ const CustomRuleDefinitionSchema = z.object({
25
91
  'excludes_keywords',
26
92
  'json_schema',
27
93
  'cost_threshold',
28
- ]),
29
- config: z.record(z.string(), z.unknown()),
30
- weight: z.number().optional(),
31
- });
94
+ ])
95
+ .describe('Check type — decides which config keys are required'),
96
+ config: z
97
+ .record(z.string(), z.unknown())
98
+ .describe('Check configuration; required keys depend on type (regex_match: pattern; min_length: min_length; max_length: max_length; contains_keywords/excludes_keywords: keywords; cost_threshold: max_cost; json_schema: none)'),
99
+ weight: z.number().positive().optional().describe('Weight in the weighted score (default 1; must be > 0)'),
100
+ }, 'definition');
32
101
  const inputSchema = {
33
102
  // 80 mirrors the persisted store's cap (custom-rule-store.ts). The tool
34
103
  // used to allow 120, so a 100-char name passed the tool schema and then
35
104
  // surfaced the store's ZodError as a raw 500 (#332). One limit, enforced
36
105
  // at the boundary, fails cleanly as a 400.
37
- name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results)'),
106
+ name: z.string().min(1).max(80).describe('Human-readable rule name (1-80 chars; used in eval results). Must be unique among deployed rules unless replace=true'),
38
107
  description: z
39
108
  .string()
40
109
  .max(500)
41
110
  .optional()
42
111
  .describe('What this rule checks for and why it matters'),
43
- evalType: z
44
- .enum(['completeness', 'relevance', 'safety', 'cost', 'custom'])
45
- .describe('Eval category this rule belongs to; determines when it fires'),
112
+ eval_type: EvalTypeSchema.optional().describe('Eval category this rule belongs to; the rule fires on evaluate_output calls whose eval_type equals it (and on eval_type="all"). Canonical snake_case spelling — pass exactly one of eval_type / evalType'),
113
+ evalType: EvalTypeSchema.optional().describe('camelCase alias of eval_type, accepted for compatibility — prefer eval_type (snake_case is canonical across the tools)'),
46
114
  severity: z
47
115
  .enum(['low', 'medium', 'high', 'critical'])
48
116
  .default('medium')
49
117
  .describe('What a FAILURE of this rule means. low/medium: informational — contributes to the weighted score only (plus dashboard sort + audit alerts). high/critical: hard-fail — a failing evaluation of this rule forces the overall passed=false regardless of the weighted score'),
50
- definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema)'),
118
+ definition: CustomRuleDefinitionSchema.describe('Check definition (regex, length, keyword, cost, or schema). Accepts exactly type, config, weight and an optional name — an unknown key is rejected'),
119
+ source_moment_id: z
120
+ .string()
121
+ .optional()
122
+ .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance). Canonical snake_case — pass exactly one of source_moment_id / sourceMomentId'),
51
123
  sourceMomentId: z
52
124
  .string()
53
125
  .optional()
54
- .describe('Optional Decision Moment ID the rule was derived from (preserves workflow-inversion provenance)'),
126
+ .describe('camelCase alias of source_moment_id, accepted for compatibility prefer source_moment_id'),
127
+ replace: z
128
+ .boolean()
129
+ .default(false)
130
+ .describe('When a rule with this name is already deployed: false (default) rejects the call; true deletes the existing same-named rule(s) and deploys this one in their place (fresh id; audit rows preserved)'),
55
131
  };
132
+ /*
133
+ * Exactly one spelling of each aliased argument. Both spellings present
134
+ * (even with equal values) is refused rather than reconciled — a caller
135
+ * sending both has a bug somewhere, and silently picking one hides it.
136
+ */
137
+ const inputSchemaWithAliases = strictInput(inputSchema).superRefine((args, ctx) => {
138
+ if (args.eval_type === undefined && args.evalType === undefined) {
139
+ ctx.addIssue({ code: 'custom', path: ['eval_type'], message: 'eval_type is required (evalType is the accepted camelCase alias)' });
140
+ }
141
+ else if (args.eval_type !== undefined && args.evalType !== undefined) {
142
+ ctx.addIssue({ code: 'custom', path: ['evalType'], message: 'pass either eval_type or evalType, not both' });
143
+ }
144
+ if (args.source_moment_id !== undefined && args.sourceMomentId !== undefined) {
145
+ ctx.addIssue({ code: 'custom', path: ['sourceMomentId'], message: 'pass either source_moment_id or sourceMomentId, not both' });
146
+ }
147
+ });
56
148
  export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
57
149
  server.registerTool('deploy_rule', {
58
150
  title: 'Deploy Custom Rule',
59
151
  description: [
60
152
  'Deploy a new custom evaluation rule that will fire on every future evaluate_output call of its eval category.',
61
153
  '',
62
- 'Sibling tools — list_rules enumerates deployed rules, delete_rule removes them, evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
154
+ 'Sibling tools — list_rules enumerates deployed rules, delete_rule removes them (or disables/re-enables them with its `enabled` argument), evaluate_output runs them. log_trace / get_traces / delete_trace handle the trace lifecycle separately; evaluate_with_llm_judge / verify_citations run semantic scoring (not heuristic-rule-driven). deploy_rule is the WRITE path that grows the custom-rule library.',
63
155
  '',
64
- 'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id; not idempotent (deploying twice creates two rules). Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
156
+ 'Behavior. Writes a row to ~/.iris/custom-rules.json (atomic write via temp file + rename) and appends a `rule.deploy` entry to the audit log (~/.iris/audit.log). The rule activates immediately for the running process and persists across restarts. Each call mints a fresh rule_id. Rule names are unique among deployed rules: deploying a name that is already deployed is REJECTED unless `replace: true`, in which case the existing same-named rule(s) are deleted (audit `rule.delete` rows written, unregistered from the live engine) and the new rule takes their place under a new id — the response lists what was replaced. Tenant-scoped in Cloud tier; OSS rules are owned by LOCAL_TENANT. Rate-limited to 20 req/min on HTTP MCP.',
65
157
  '',
66
- 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" } }`. The returned rule is the canonical persisted form; save the `id` if you plan to update or delete later.',
158
+ 'Output shape. Returns JSON: `{ "rule": { "id": "rule-XXXX", "name", "description", "evalType", "severity", "definition", "enabled": true, "createdAt", "updatedAt", "version": 1, "sourceMomentId?" }, "replaced?": [{ "id", "evalType", "severity" }], "warning?": string }`. The returned rule is the canonical persisted form; save the `id` if you plan to disable or delete later. `replaced` and `warning` appear only when `replace: true` removed an earlier rule of the same name.',
67
159
  '',
68
- "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `sourceMomentId` field preserves provenance — downstream audit can trace the rule back to the moment that inspired it. Combine with evaluate_output + get_traces: 1) evaluate_output surfaces failures; 2) get_traces filters to the failure set; 3) analyze the pattern; 4) deploy_rule bakes it into the default eval path.",
160
+ "Use when an agent observes a recurring failure pattern and decides to enforce it as a standing rule. The `source_moment_id` field preserves provenance — downstream audit can trace the rule back to the moment that inspired it. Combine with evaluate_output + get_traces: 1) evaluate_output surfaces failures; 2) get_traces filters to the failure set; 3) analyze the pattern; 4) deploy_rule bakes it into the default eval path.",
69
161
  '',
70
- "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) for dry-run validation against sample output. Don't use to EDIT an existing rule this call only creates; edits require a dedicated flow (coming in v0.5). To update a rule today: delete_rule then deploy_rule with the new definition.",
162
+ "Don't use to VALIDATE a rule before committing — deploy writes immediately. Use the dashboard's preview endpoint (POST /api/v1/rules/custom/preview) to replay a definition against recent stored traces first. To UPDATE a rule: call deploy_rule with the same name and `replace: true` (the old rule is deleted, the new one gets a fresh id), or delete_rule then deploy_rule. To pause a rule without losing it: delete_rule with `enabled: false`.",
71
163
  '',
72
- 'Parameters. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). evalType determines WHEN the rule fires (must match the eval_type your evaluate_output calls use; e.g., a "completeness" rule fires on every evaluate_output where eval_type="completeness" OR eval_type="custom"). severity decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation — the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). Invalid configs are now REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. sourceMomentId is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). Defaults: severity="medium".',
164
+ 'Parameters. Argument names are snake_case (eval_type, source_moment_id) — the camelCase spellings evalType / sourceMomentId are accepted as aliases for compatibility, but pass only one spelling of each. name is 1-80 chars (Zod-enforced min/max — the same cap the persisted store applies); appears in eval_result rule_results (alongside the rule id as `ruleId`) so make it human-readable. description is optional, max 500 chars (used in dashboard tooltips). eval_type determines WHEN the rule fires: a deployed rule runs ONLY on evaluate_output calls whose eval_type equals the rule\'s eval_type, plus eval_type="all" (which runs every bundle) — a "completeness" rule does NOT fire on eval_type="safety" or on eval_type="custom"; eval_type="custom" runs rules deployed under "custom" (and the call\'s inline custom_rules) and nothing else. severity decides what a FAILURE of the rule does: low/medium failures only lower the weighted score (and drive dashboard sort + audit alerts); high/critical failures HARD-FAIL the evaluation — the overall `passed` is forced to false regardless of the weighted score, and the rule is listed in the response\'s `critical_failures`. Severity never changes the numeric score itself (that uses the rule\'s weight). definition.type and definition.config must match (e.g., regex_match needs config.pattern; cost_threshold needs config.max_cost; min_length needs config.min_length; max_length needs config.max_length; contains_keywords/excludes_keywords need config.keywords). definition.name is optional and, if given, overwritten by the top-level name. Invalid configs are REJECTED at deploy time with the offending field named, instead of deploying and then failing every evaluation. source_moment_id is optional but recommended (preserves workflow-inversion provenance from Make-This-A-Rule composer). replace defaults to false. Defaults: severity="medium", replace=false.',
73
165
  '',
74
- "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars). Throws 400 on empty `name` or `name` over 80 chars. Any evalType/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
166
+ "Error modes. Throws 400 on invalid definition (Zod rejects — e.g., regex that fails safe-regex2 ReDoS check, or length > 1000 chars), on an unknown key inside `definition` (the valid keys are listed; config keys are free-form), on empty `name` or `name` over 80 chars, on a non-positive weight, and when both spellings of an aliased argument are passed. Throws when a rule with the same name is already deployed and `replace` is not true — the message names the existing rule id so you can delete it, replace it, or pick another name. Any eval_type/definition.type combination is valid (a regex_match rule can enforce a safety policy; a max_length rule can express completeness) — there is no category/type mismatch error. Returns 429 when HTTP rate limit exceeded. File-write failures (disk full, read-only fs) propagate as 500; the audit log is best-effort and does not block deploy.",
75
167
  ].join('\n'),
76
- inputSchema: strictInput(inputSchema),
168
+ inputSchema: inputSchemaWithAliases,
77
169
  annotations: {
78
170
  readOnlyHint: false,
79
171
  destructiveHint: false,
@@ -81,6 +173,8 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
81
173
  openWorldHint: false,
82
174
  },
83
175
  }, async (args) => {
176
+ const evalType = (args.eval_type ?? args.evalType);
177
+ const sourceMomentId = args.source_moment_id ?? args.sourceMomentId;
84
178
  // Server overrides the inner definition's `name` so it always matches
85
179
  // the user-facing rule name — same normalization the dashboard's
86
180
  // deploy route applies. Also keeps the tool's 80-char cap authoritative
@@ -90,14 +184,18 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
90
184
  ...args.definition,
91
185
  name: args.name,
92
186
  };
187
+ // Same-name redeploy (#373) — shared with the dashboard's deploy
188
+ // route; see retireSameNamedRules above. Throws (nothing deployed)
189
+ // when the name is taken and replace is false.
190
+ const replaced = retireSameNamedRules(customRuleStore, evalEngine, LOCAL_TENANT, args.name, args.replace, 'mcp');
93
191
  // OSS: MCP tools operate under LOCAL_TENANT. See list-rules.ts for context.
94
192
  const rule = customRuleStore.deploy(LOCAL_TENANT, {
95
193
  name: args.name,
96
194
  description: args.description,
97
- evalType: args.evalType,
195
+ evalType,
98
196
  severity: args.severity,
99
197
  definition,
100
- sourceMomentId: args.sourceMomentId,
198
+ sourceMomentId,
101
199
  user: 'mcp',
102
200
  });
103
201
  // Register with the live engine so the rule fires on the very next
@@ -111,7 +209,12 @@ export function registerDeployRuleTool(server, customRuleStore, evalEngine) {
111
209
  content: [
112
210
  {
113
211
  type: 'text',
114
- text: JSON.stringify({ rule }),
212
+ text: JSON.stringify({
213
+ rule,
214
+ ...(replaced.length > 0
215
+ ? { replaced, warning: replacedRulesWarning(args.name, replaced) }
216
+ : {}),
217
+ }),
115
218
  },
116
219
  ],
117
220
  };
@@ -1,37 +1,45 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
- import { strictInput } from './strict-input.js';
4
- const CustomRuleSchema = z.object({
5
- name: z.string(),
3
+ import { strictInput, strictNested } from './strict-input.js';
4
+ import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
5
+ /*
6
+ * Strict one level down (#376): `{ name, type, config, wieght: 5 }` used to
7
+ * parse with `wieght` silently discarded, so the rule ran at weight 1 and
8
+ * the score moved for a reason the response could not show. `config` stays
9
+ * a free-form record — its keys depend on `type` and are validated by the
10
+ * rule itself (a broken config reports skipped + configInvalid).
11
+ */
12
+ const CustomRuleSchema = strictNested({
13
+ name: z.string().min(1).describe('Rule name as it will appear in rule_results'),
6
14
  type: z.enum([
7
15
  'regex_match', 'regex_no_match', 'min_length', 'max_length',
8
16
  'contains_keywords', 'excludes_keywords', 'json_schema', 'cost_threshold',
9
- ]),
10
- config: z.record(z.string(), z.unknown()),
11
- weight: z.number().optional(),
12
- });
17
+ ]).describe('Check type — decides which config keys the rule reads'),
18
+ config: z.record(z.string(), z.unknown()).describe('Check configuration; keys depend on type (pattern, min_length, keywords, max_cost, …)'),
19
+ weight: z.number().positive().optional().describe('Weight in the weighted score (default 1; must be > 0)'),
20
+ }, 'a custom_rules entry');
13
21
  const inputSchema = {
14
22
  output: z.string().describe('The output text to evaluate (the agent\'s response that gets scored against rules)'),
15
23
  // .optional() rather than .default('completeness') so the handler can tell
16
24
  // "caller chose completeness" apart from "caller never chose" — the second
17
25
  // case gets a note in the response saying safety rules did not run. The
18
26
  // effective default is still completeness.
19
- eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom — picks which built-in rules fire. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
20
- expected: z.string().optional().describe('Expected output for comparison — REQUIRED when eval_type="relevance" (used as keyword-overlap target)'),
21
- input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — improves relevance scoring and grounds the safety bundle\'s hallucination signals'),
22
- trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through'),
27
+ eval_type: z.enum(['completeness', 'relevance', 'safety', 'cost', 'custom', 'all']).optional().describe('Rule bundle to apply: completeness | relevance | safety | cost | custom | all — picks which built-in rules fire. "all" runs every bundle in one call and adds a per-category breakdown. Defaults to "completeness" when omitted (the response then carries a note that safety rules did not run)'),
28
+ expected: z.string().optional().describe('Expected output for comparison — consulted only by the completeness bundle\'s expected_coverage rule; NOT used by relevance (the relevance rules compare the output against `input`)'),
29
+ input: z.string().optional().describe('Original input for context (the ask + any source material the agent was given) — REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it and skip without it); also grounds the safety bundle\'s hallucination signals'),
30
+ trace_id: z.string().optional().describe('Link evaluation to a trace — surfaces this eval in the dashboard\'s trace drill-through. Must be the id of a stored trace (from log_trace / get_traces); an unknown id is rejected before anything is evaluated'),
23
31
  // .max(10): inline rules skip the deploy-time probe, and the engine runs
24
32
  // rules synchronously — without a cap, one request carrying N sandbox-
25
33
  // defeating regex rules stalls the server linearly in N (measured 9.3s at
26
34
  // N=50). Ten is ample for per-call rules; persistent sets belong in
27
35
  // deploy_rule, where deploy-time validation probes each pattern.
28
- custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these'),
29
- cost_usd: z.number().optional().describe('Cost in USD — only consulted when eval_type="cost" (compared against cost_threshold rules)'),
36
+ custom_rules: z.array(CustomRuleSchema).max(10).optional().describe('Custom evaluation rules, max 10 per call (deploy persistent rule sets via deploy_rule instead) — fires REGARDLESS of eval_type; pass eval_type="custom" if you want ONLY these. Each entry accepts exactly name, type, config, weight — an unknown key (e.g. a misspelled weight) is rejected'),
37
+ cost_usd: z.number().optional().describe('Cost in USD — consulted by the cost bundle (eval_type="cost" or "all") AND by any cost_threshold custom rule regardless of eval_type; omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped)'),
30
38
  token_usage: z.object({
31
39
  prompt_tokens: z.number().optional(),
32
40
  completion_tokens: z.number().optional(),
33
41
  total_tokens: z.number().optional(),
34
- }).optional().describe('Token usage breakdown — only consulted when eval_type="cost" (used for token-budget rules)'),
42
+ }).optional().describe('Token usage breakdown — only consulted by the cost bundle (eval_type="cost" or "all"; used for token-budget rules)'),
35
43
  };
36
44
  export function registerEvaluateOutputTool(server, storage, evalEngine) {
37
45
  server.registerTool('evaluate_output', {
@@ -43,17 +51,17 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
43
51
  '',
44
52
  'Behavior. Deterministic, in-process scoring — same inputs always produce the same result. Writes one eval_result row to Iris storage (linked to trace_id if provided; unlinked otherwise). No external network calls in heuristic mode (v0.4 adds an llm_as_judge eval_type that DOES call LLM APIs; see the separate evaluate_with_llm_judge tool for that). Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Runs in ~5-50ms for rule-based evaluation.',
45
53
  '',
46
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "rule_results": [{ "ruleName", "passed", "score", "message", "skipped?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "note?": string }`. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
54
+ 'Output shape. Returns JSON: `{ "id": "<uuid>", "eval_type": "<bundle that ran>", "score": 0..1, "passed": boolean, "critical_failures?": string[], "critical_skipped?": string[], "rule_results": [{ "ruleName", "ruleId?", "category?", "passed", "score", "message", "skipped?", "skipReason?", "budgetExceeded?", "configInvalid?" }], "suggestions": string[], "rules_evaluated": number, "rules_skipped": number, "insufficient_data": boolean, "categories?": { "<bundle>": { "score", "passed", "rules_evaluated", "rules_skipped", "insufficient_data", "critical_failures?", "critical_skipped?" } }, "note?": string }`. `ruleId` is present on results produced by a deployed rule (rule-XXXX) so two rules sharing a name stay distinguishable. `categories` appears only for eval_type="all" and carries one entry per bundle that had rules, each with the same threshold + critical-veto semantics as a single-bundle run; `category` on each rule result says which bundle it came from. `insufficient_data=true` means no applicable rules fired (e.g., safety eval with only cost data). `note` appears only when eval_type was omitted, naming the defaulted bundle and that safety rules did not run.',
47
55
  '',
48
- 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran — a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` — plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing.',
56
+ 'What `passed` means. `score` and `passed` answer different questions. `score` is the weighted average across the rules that ran — a 0..1 quality gradient. `passed` is the ship/no-ship verdict: true only when the score clears the pass threshold (default 0.7, configurable via config `eval.defaultThreshold`) AND no critical rule failed. Critical rules HARD-FAIL: if one fails, `passed` is false regardless of the weighted score, and the culprits are listed in `critical_failures`. The critical rules are the genuine safety violations — `no_pii`, `no_injection_patterns`, `no_blocklist_words` — plus any deployed custom rule with severity high/critical. A leaked SSN can never be averaged away by other rules passing. For eval_type="all" the veto spans every bundle: one critical failure anywhere forces the overall `passed` to false. One caveat, stated because it is reachable on purpose: a critical rule that SKIPPED did not judge the output and therefore cannot veto — a regex rule whose match blew the 100ms sandbox budget on crafted output skips, so `passed` can be true with no `critical_failures`. Every such rule is named in `critical_skipped`. If your gate must fail closed, treat a non-empty `critical_skipped` as UNKNOWN, not clean.',
49
57
  '',
50
- 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, sentence count, relevance to input), `relevance` (keyword overlap, topic consistency), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), or `custom` (bring your own rules via `custom_rules`).',
58
+ 'Use when you want a quality score on a specific output — typically after log_trace records the execution. Pass `eval_type` to route to the right rule bundle: `completeness` (length, non-empty output, sentence count, coverage of `expected`), `relevance` (keyword overlap and topic consistency against `input`), `safety` (PII leak, prompt injection, hallucination markers, stub-output detection — pass `input` so the hallucination signals can cross-check the output against the material the agent was given), `cost` (budget threshold), `custom` (bring your own rules via `custom_rules`), or `all` (every bundle above in one call — completeness, relevance, safety, cost, plus rules deployed under "custom" and any inline custom_rules — with per-category scores in `categories` and one overall verdict; rules whose context is missing, such as relevance without `input` or cost without `cost_usd`, skip and are excluded from the score exactly as in a single-bundle run).',
51
59
  '',
52
60
  'Don\'t use when the output is empty or has no applicable rules — the eval_type decides which rules apply, and invalid combinations return score=0 + insufficient_data=true (not an error, but not actionable). Don\'t use to VALIDATE JSON schemas directly (use your language\'s JSON Schema validator — Iris\'s `json_schema` custom rule type is for output-shape assertions, not arbitrary validation).',
53
61
  '',
54
- 'Parameters. expected is REQUIRED when eval_type="relevance" (used as the comparison target for keyword overlap + topic consistency); ignored for other eval_types. cost_usd + token_usage are ONLY consulted when eval_type="cost" (ignored otherwise). custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together). trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through). input adds context to keyword-overlap relevance checks AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
62
+ 'Parameters. input is REQUIRED when eval_type="relevance" (keyword_overlap and topic_consistency compare the output against it; without it both rules skip and the response reports insufficient_data=true) AND grounds the safety bundle\'s hallucination signals (without it those signals stay silent rather than guess); ignored otherwise. expected is consulted only by the completeness bundle\'s expected_coverage rule; ignored for other eval_types — it is NOT the relevance target. cost_usd is consulted by the cost bundle AND by any cost_threshold custom rule regardless of eval_type — omit it and such a rule skips rather than passes (a critical one is listed in critical_skipped); token_usage is ONLY consulted by the cost bundle. custom_rules ALWAYS fires regardless of eval_type — pass eval_type="custom" if you want ONLY your rules to run (otherwise both your rules AND the eval_type bundle run together); each entry takes exactly name, type, config and weight. trace_id is optional but recommended (linking the eval to its trace surfaces it in the dashboard\'s drill-through) and must name a stored trace. Defaults: eval_type="completeness" — and when you rely on that default, the response carries a `note` reminding you that the safety bundle did not run.',
55
63
  '',
56
- 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped). Throws on malformed custom_rules (Zod rejects) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). Returns 400 on regex patterns that fail safe-regex2 ReDoS check or exceed 1000-char limit. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
64
+ 'Error modes. Throws on unknown argument names (strict schema — a misspelled argument is rejected with the valid argument list, never silently dropped), and likewise on an unknown key inside a custom_rules entry (e.g. `wieght`) — the valid keys are listed; a rule\'s `config` keys are free-form and are not checked here. Throws on malformed custom_rules (Zod rejects the shape: missing name/type, unknown type, non-object config, non-positive weight) and on more than 10 custom_rules in one call (use deploy_rule for persistent rule sets). Throws when trace_id does not match a stored trace — checked BEFORE evaluating, so nothing is scored or written; the message names the trace_id. An inline rule whose CONFIG is unusable — a regex that fails the safe-regex2 ReDoS check or exceeds the 1000-char limit, a missing or non-string config.pattern, non-string keywords — does NOT error: that rule reports skipped with configInvalid=true and a skipReason naming the field, and the other rules still run (deploy_rule rejects the same configs with a 400 at deploy time). Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. The eval itself never throws — failing rules report `passed: false` with a message, they don\'t bubble exceptions. A regex that exceeds the 100ms sandbox matching budget on a given output reports skipped with budgetExceeded=true instead of hanging the server (fail-open per rule — gate on that flag if you must fail closed).',
57
65
  ].join('\n'),
58
66
  inputSchema: strictInput(inputSchema),
59
67
  annotations: {
@@ -63,25 +71,34 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
63
71
  openWorldHint: false, // No external network in heuristic mode; LLM-as-judge has its own tool with openWorldHint:true
64
72
  },
65
73
  }, async (args) => {
74
+ // Refuse an unknown trace_id up front (#376): the old path ran the
75
+ // evaluation and then surfaced SQLite's "FOREIGN KEY constraint
76
+ // failed", which names neither the field nor the fix.
77
+ if (args.trace_id) {
78
+ await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
79
+ }
66
80
  // Track omission explicitly: a caller who never chose a bundle gets
67
81
  // the completeness default AND a note saying so — six of seven UAT
68
82
  // personas read passed:true on PII-laden text with no hint that the
69
83
  // safety bundle never ran.
70
84
  const evalTypeOmitted = args.eval_type === undefined;
71
- const evalType = (args.eval_type ?? 'completeness');
72
- const result = evalEngine.evaluate(evalType, {
85
+ const context = {
73
86
  output: args.output,
74
87
  expected: args.expected,
75
88
  input: args.input,
76
89
  costUsd: args.cost_usd,
77
90
  tokenUsage: args.token_usage,
78
- }, args.custom_rules);
91
+ };
92
+ const customRules = args.custom_rules;
93
+ const result = args.eval_type === 'all'
94
+ ? evalEngine.evaluateAll(context, customRules)
95
+ : evalEngine.evaluate((args.eval_type ?? 'completeness'), context, customRules);
79
96
  if (args.trace_id) {
80
97
  result.trace_id = args.trace_id;
81
98
  }
82
99
  // OSS single-tenant: MCP tool callers are the local user. Cloud
83
100
  // will derive tenant from the authenticated MCP session.
84
- await storage.insertEvalResult(LOCAL_TENANT, result);
101
+ await insertLinkedEvalResult(storage, LOCAL_TENANT, result);
85
102
  return {
86
103
  content: [
87
104
  {
@@ -95,14 +112,23 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
95
112
  score: result.score,
96
113
  passed: result.passed,
97
114
  ...(result.critical_failures ? { critical_failures: result.critical_failures } : {}),
115
+ // The other half of the veto contract. The engine names every
116
+ // critical rule that SKIPPED (budget-killed regex, missing cost
117
+ // data) so a fail-closed gate can treat the eval as unknown;
118
+ // this response used to drop the field, so the gate the
119
+ // description tells users to write keyed on something that
120
+ // never arrived and read passed:true as clean.
121
+ ...(result.critical_skipped ? { critical_skipped: result.critical_skipped } : {}),
98
122
  rule_results: result.rule_results,
99
123
  suggestions: result.suggestions,
100
124
  rules_evaluated: result.rules_evaluated,
101
125
  rules_skipped: result.rules_skipped,
102
126
  insufficient_data: result.insufficient_data,
127
+ // Per-bundle breakdown — eval_type="all" only.
128
+ ...(result.categories ? { categories: result.categories } : {}),
103
129
  ...(evalTypeOmitted
104
130
  ? {
105
- note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them.',
131
+ note: 'eval_type was omitted, so the default "completeness" bundle ran. Safety rules (PII, injection, blocklist, stub, hallucination) were NOT part of this evaluation — pass eval_type="safety" to run them, or eval_type="all" to run every bundle.',
106
132
  }
107
133
  : {}),
108
134
  }),
@@ -4,6 +4,7 @@ import { evaluateWithLLMJudge } from '../eval/llm-judge/evaluator.js';
4
4
  import { findPricing } from '../eval/llm-judge/pricing.js';
5
5
  import { generateEvalId } from '../utils/ids.js';
6
6
  import { strictInput } from './strict-input.js';
7
+ import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
7
8
  const inputSchema = {
8
9
  output: z.string().min(1).describe('The agent output text to evaluate'),
9
10
  template: z
@@ -16,7 +17,7 @@ const inputSchema = {
16
17
  input: z.string().optional().describe('User question / prompt that produced the output (improves accuracy for helpfulness/safety)'),
17
18
  expected: z.string().optional().describe('Reference answer (required for correctness template)'),
18
19
  source_material: z.string().optional().describe('Provided RAG sources (required for faithfulness template)'),
19
- trace_id: z.string().optional().describe('Link this evaluation to a trace'),
20
+ trace_id: z.string().optional().describe('Link this evaluation to a stored trace (id from log_trace / get_traces); an unknown id is rejected BEFORE the judge is called'),
20
21
  max_cost_usd: z.number().positive().optional().describe('Cost cap in USD; defaults to IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or 0.25'),
21
22
  max_output_tokens: z.number().int().positive().max(4096).optional().describe('Judge output token cap; default 512'),
22
23
  temperature: z.number().min(0).max(2).optional().describe('Sampling temperature; default 0 (deterministic)'),
@@ -70,9 +71,9 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
70
71
  '',
71
72
  "Don't use for simple regex/length/keyword checks (use evaluate_output with heuristic rules — they're free, deterministic, 1000x faster). Don't use without an API key set (IRIS_ANTHROPIC_API_KEY or IRIS_OPENAI_API_KEY). Don't use on very large outputs (>8K tokens) without raising max_cost_usd — the pre-check will refuse the call.",
72
73
  '',
73
- 'Parameters. model is required (no default — pick consciously since cost varies 100x across models). provider is auto-detected from the model name; override only for ambiguous IDs. expected is REQUIRED when template="correctness" (the reference answer to compare against); ignored for other templates. source_material is REQUIRED when template="faithfulness" (the RAG sources to ground against); ignored otherwise. input is optional but improves scoring on helpfulness/safety templates (gives the judge the user prompt that produced the output). max_cost_usd defaults to env var IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or $0.25 — the worst-case cost is computed BEFORE the call (input_tokens × prompt_price + max_output_tokens × completion_price); call refused upfront if it would exceed. max_output_tokens caps the judge response (default 512, max 4096); higher = more rationale detail + more cost. temperature default 0 (deterministic). timeout_ms default 60000. trace_id optional but recommended (links eval to trace in dashboard). Defaults: temperature=0, max_output_tokens=512, max_cost_usd=$0.25, timeout_ms=60000.',
74
+ 'Parameters. model is required (no default — pick consciously since cost varies 100x across models). provider is auto-detected from the model name; override only for ambiguous IDs. expected is REQUIRED when template="correctness" (the reference answer to compare against); ignored for other templates. source_material is REQUIRED when template="faithfulness" (the RAG sources to ground against); ignored otherwise. input is optional but improves scoring on helpfulness/safety templates (gives the judge the user prompt that produced the output). max_cost_usd defaults to env var IRIS_LLM_JUDGE_MAX_COST_USD_PER_EVAL or $0.25 — the worst-case cost is computed BEFORE the call (input_tokens × prompt_price + max_output_tokens × completion_price, PLUS the same for the one retry that fires if the judge\'s first reply is not valid JSON); call refused upfront if that two-attempt worst case would exceed. When a retry does run, the reported input_tokens / output_tokens / cost_usd / latency_ms are totals across both attempts. max_output_tokens caps the judge response (default 512, max 4096); higher = more rationale detail + more cost. temperature default 0 (deterministic). timeout_ms default 60000. trace_id optional but recommended (links eval to trace in dashboard). Defaults: temperature=0, max_output_tokens=512, max_cost_usd=$0.25, timeout_ms=60000.',
74
75
  '',
75
- 'Error modes. Throws when the required API key env var is missing. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
76
+ 'Error modes. Throws when the required API key env var is missing. Throws when trace_id does not match a stored trace — checked before the provider call, so no money is spent and nothing is written. Throws when the estimated worst-case cost exceeds max_cost_usd (raise the cap or trim prompts). Throws LLMJudgeError on provider errors — kind=`auth` on 401/403, `rate_limit` on 429 (auto-retried once), `server_error` on 5xx, `timeout` on abort, `malformed_response` when the judge fails to emit valid JSON on both attempts. Throws "Unknown model" for unsupported model IDs — update src/eval/llm-judge/pricing.ts first.',
76
77
  ].join('\n'),
77
78
  inputSchema: strictInput(inputSchema),
78
79
  annotations: {
@@ -85,6 +86,12 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
85
86
  const provider = args.provider ?? inferProvider(args.model);
86
87
  const apiKey = resolveApiKey(provider);
87
88
  const maxCostUsd = resolveMaxCost(args.max_cost_usd);
89
+ // An unknown trace_id is refused BEFORE the provider call — the old
90
+ // path spent the judge's money and then failed the INSERT with a raw
91
+ // "FOREIGN KEY constraint failed" (#376).
92
+ if (args.trace_id) {
93
+ await assertTraceExists(storage, LOCAL_TENANT, args.trace_id);
94
+ }
88
95
  const result = await evaluateWithLLMJudge({
89
96
  output: args.output,
90
97
  template: args.template,
@@ -105,7 +112,7 @@ export function registerEvaluateWithLLMJudgeTool(server, storage) {
105
112
  // judge doesn't fit completeness/relevance/safety/cost taxonomy
106
113
  // cleanly — it spans all four. The rule_results payload carries
107
114
  // the full judge provenance.
108
- await storage.insertEvalResult(LOCAL_TENANT, {
115
+ await insertLinkedEvalResult(storage, LOCAL_TENANT, {
109
116
  id: evalId,
110
117
  trace_id: args.trace_id,
111
118
  eval_type: 'custom',
@@ -1,3 +1,30 @@
1
+ import { z } from 'zod';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { IStorageAdapter } from '../types/query.js';
4
+ export declare function isIsoTimestamp(value: string): boolean;
5
+ /**
6
+ * The `since` / `until` field schema. Shared with the dashboard's trace
7
+ * query (dashboard/validation.ts) so both read paths refuse the same
8
+ * unparseable bounds with the same hint.
9
+ */
10
+ export declare const isoTimestamp: z.ZodString;
11
+ /** The cross-field bounds a trace query can carry. */
12
+ export interface TraceRangeArgs {
13
+ min_score?: number;
14
+ max_score?: number;
15
+ since?: string;
16
+ until?: string;
17
+ }
18
+ /**
19
+ * Cross-field checks the per-field schema cannot express (#373). A range
20
+ * whose bounds cross — min_score 0.9 / max_score 0.1, or since after until
21
+ * — used to be accepted and return an empty page, which reads as "no such
22
+ * traces" when the truth is "no trace could ever match this". Refusing it
23
+ * with the two values named is what the argument descriptions promise.
24
+ *
25
+ * One function for both read paths: `get_traces` (MCP) and
26
+ * `GET /api/v1/traces` (dashboard) call it from their `superRefine`, so a
27
+ * bound the tool rejects is never one the HTTP query quietly accepts.
28
+ */
29
+ export declare function addTraceRangeIssues(args: TraceRangeArgs, ctx: z.RefinementCtx): void;
3
30
  export declare function registerGetTracesTool(server: McpServer, storage: IStorageAdapter): void;
@@ -1,22 +1,74 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { strictInput } from './strict-input.js';
4
+ /*
5
+ * An ISO-8601 instant (2026-08-01T00:00:00Z, offsets allowed) or calendar
6
+ * date (2026-08-01). Stored timestamps are ISO strings and the adapter
7
+ * compares them lexically, so both forms bound the query correctly; a
8
+ * date-only value is the natural "since the 1st" spelling and is kept
9
+ * rather than forced into a full timestamp.
10
+ */
11
+ const isoInstant = z.iso.datetime({ offset: true });
12
+ const isoDate = z.iso.date();
13
+ export function isIsoTimestamp(value) {
14
+ return isoInstant.safeParse(value).success || isoDate.safeParse(value).success;
15
+ }
16
+ const TIMESTAMP_HINT = 'must be an ISO 8601 timestamp (e.g. 2026-08-01T00:00:00Z) or date (2026-08-01)';
17
+ /**
18
+ * The `since` / `until` field schema. Shared with the dashboard's trace
19
+ * query (dashboard/validation.ts) so both read paths refuse the same
20
+ * unparseable bounds with the same hint.
21
+ */
22
+ export const isoTimestamp = z.string().refine(isIsoTimestamp, {
23
+ // The rejected value is echoed so the error names what was sent, as the
24
+ // crossed-bound errors already do (v0.6.0 acceptance pass, B8/C9).
25
+ error: (issue) => `${JSON.stringify(issue.input)} ${TIMESTAMP_HINT}`,
26
+ });
27
+ /**
28
+ * Cross-field checks the per-field schema cannot express (#373). A range
29
+ * whose bounds cross — min_score 0.9 / max_score 0.1, or since after until
30
+ * — used to be accepted and return an empty page, which reads as "no such
31
+ * traces" when the truth is "no trace could ever match this". Refusing it
32
+ * with the two values named is what the argument descriptions promise.
33
+ *
34
+ * One function for both read paths: `get_traces` (MCP) and
35
+ * `GET /api/v1/traces` (dashboard) call it from their `superRefine`, so a
36
+ * bound the tool rejects is never one the HTTP query quietly accepts.
37
+ */
38
+ export function addTraceRangeIssues(args, ctx) {
39
+ if (args.min_score !== undefined && args.max_score !== undefined && args.min_score > args.max_score) {
40
+ ctx.addIssue({
41
+ code: 'custom',
42
+ path: ['min_score'],
43
+ message: `min_score (${args.min_score}) must be <= max_score (${args.max_score}) — the range is empty and no trace could match it`,
44
+ });
45
+ }
46
+ if (args.since !== undefined && args.until !== undefined && Date.parse(args.since) > Date.parse(args.until)) {
47
+ ctx.addIssue({
48
+ code: 'custom',
49
+ path: ['since'],
50
+ message: `since (${args.since}) must not be later than until (${args.until}) — the window is empty and no trace could match it`,
51
+ });
52
+ }
53
+ }
4
54
  const inputSchema = {
5
55
  agent_name: z.string().optional().describe('Filter by agent name — exact match (no wildcards in v0.4)'),
6
56
  framework: z.string().optional().describe('Filter by agent framework — exact match (e.g., langchain, autogen)'),
7
- since: z.string().optional().describe('ISO timestamp lower bound — return traces with timestamp >= this'),
8
- until: z.string().optional().describe('ISO timestamp upper bound — return traces with timestamp < this'),
9
- min_score: z.number().optional().describe('Minimum eval score filter (0..1) — applied to LATEST eval per trace, not all evals'),
10
- max_score: z.number().optional().describe('Maximum eval score filter (0..1) — applied to LATEST eval per trace'),
57
+ since: isoTimestamp.optional().describe('ISO 8601 timestamp (or date) lower bound — return traces with timestamp >= this; anything that is not an ISO timestamp is rejected, never treated as "no bound"'),
58
+ until: isoTimestamp.optional().describe('ISO 8601 timestamp (or date) upper bound — return traces with timestamp <= this; must not be earlier than `since`'),
59
+ min_score: z.number().min(0).max(1).optional().describe('Minimum eval score filter (0..1; values outside are rejected) — applied to LATEST eval per trace, not all evals; must be <= max_score when both are set'),
60
+ max_score: z.number().min(0).max(1).optional().describe('Maximum eval score filter (0..1; values outside are rejected) — applied to LATEST eval per trace'),
11
61
  // Mirrors traceQuerySchema in dashboard/validation.ts — both capture paths
12
62
  // (MCP tool, HTTP query) enforce the same 1..1000 bound. Unclamped, limit:-1
13
63
  // meant "LIMIT -1" in SQLite, i.e. every row (#332).
14
64
  limit: z.number().int().min(1).max(1000).default(50).describe('Results per page (default 50, max 1000 — values >1000 return 400)'),
15
- offset: z.number().default(0).describe('Zero-based pagination offset — skip first N results'),
65
+ offset: z.number().int().min(0).default(0).describe('Zero-based pagination offset — skip first N results (non-negative integer)'),
16
66
  sort_by: z.enum(['timestamp', 'latency_ms', 'cost_usd']).default('timestamp').describe('Sort by timestamp | latency_ms | cost_usd (default timestamp)'),
17
67
  sort_order: z.enum(['asc', 'desc']).default('desc').describe('Sort order: asc | desc (default desc — most recent / highest first)'),
18
68
  include_summary: z.boolean().default(false).describe('Include dashboard summary stats in same response — saves a round-trip when ingesting for dashboards'),
19
69
  };
70
+ // Cross-field range checks — see addTraceRangeIssues above.
71
+ const inputSchemaWithRanges = strictInput(inputSchema).superRefine(addTraceRangeIssues);
20
72
  export function registerGetTracesTool(server, storage) {
21
73
  server.registerTool('get_traces', {
22
74
  title: 'Get Traces',
@@ -33,11 +85,11 @@ export function registerGetTracesTool(server, storage) {
33
85
  '',
34
86
  'Don\'t use to score a trace (use evaluate_output). Don\'t use to create a trace (use log_trace). Don\'t use as a live event stream — it\'s a query, not a subscription; poll with exponential backoff or use the dashboard\'s SSE endpoint for real-time.',
35
87
  '',
36
- 'Parameters. limit defaults to 50, max 1000 (anything higher returns 400). offset is zero-based pagination. min_score / max_score filter on the LATEST eval per trace, not all evals (so a trace with one failing + one passing eval may or may not match depending on which landed last). Combining since + sort_by="latency_ms" + sort_order="desc" is the canonical "find slow recent traces" query. include_summary returns dashboard-style aggregates in the SAME response (saves a round-trip; use true for dashboard ingest, false for analytics queries that don\'t need them). agent_name and framework are exact-match (no wildcards in v0.4). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
88
+ 'Parameters. limit defaults to 50, max 1000 (anything higher returns 400). offset is zero-based pagination (non-negative integer). since / until must be ISO 8601 timestamps or dates — `since` is inclusive (timestamp >= since), `until` is inclusive (timestamp <= until), and `since` may not be later than `until`. min_score / max_score are 0..1 and filter on the LATEST eval per trace, not all evals (so a trace with one failing + one passing eval may or may not match depending on which landed last); min_score may not exceed max_score. Combining since + sort_by="latency_ms" + sort_order="desc" is the canonical "find slow recent traces" query. include_summary returns dashboard-style aggregates in the SAME response (saves a round-trip; use true for dashboard ingest, false for analytics queries that don\'t need them). agent_name and framework are exact-match (no wildcards in v0.4). Defaults: limit=50, offset=0, sort_by="timestamp", sort_order="desc", include_summary=false.',
37
89
  '',
38
- 'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000. Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
90
+ 'Error modes. Returns 400 on invalid sort_by / sort_order (Zod enum). Returns 400 if limit > 1000 or offset < 0. Returns 400 — naming both values — on an empty range: min_score > max_score, since later than until, a score outside 0..1, or a since/until that is not an ISO 8601 timestamp or date (an unparseable bound is refused, never silently ignored). Returns 429 when HTTP rate limit exceeded. Storage failures propagate as 500. Empty result with `total: 0` on no matches (not an error).',
39
91
  ].join('\n'),
40
- inputSchema: strictInput(inputSchema),
92
+ inputSchema: inputSchemaWithRanges,
41
93
  annotations: {
42
94
  readOnlyHint: true, // Pure query: never writes, never deletes
43
95
  destructiveHint: false, // Inverse of readOnly — trivially false