@iris-eval/mcp-server 0.1.9 → 0.2.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.
@@ -1 +1 @@
1
- :root{--bg-primary:#0a0a0b;--bg-secondary:#141416;--bg-tertiary:#1c1c1f;--bg-hover:#252528;--text-primary:#fafafa;--text-secondary:#a1a1aa;--text-muted:#71717a;--accent-primary:#0d9488;--accent-primary-hover:#14b8a6;--accent-success:#22c55e;--accent-error:#ef4444;--accent-warning:#f59e0b;--accent-tool:#3b82f6;--accent-llm:#a855f7;--border-color:#27272a;--border-radius:8px;--border-radius-sm:4px;--border-radius-lg:12px;--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", monospace;--font-size-xs:.75rem;--font-size-sm:.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.25rem;--font-size-2xl:1.5rem;--font-size-3xl:2rem;--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;--space-5:1.25rem;--space-6:1.5rem;--space-8:2rem;--space-10:2.5rem;--space-12:3rem;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080;--transition-fast:.15s ease;--transition-base:.2s ease}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-sans);font-size:var(--font-size-base);color:var(--text-primary);background-color:var(--bg-primary);-webkit-font-smoothing:antialiased;line-height:1.5}a{color:var(--accent-primary);text-decoration:none}a:hover{color:var(--accent-primary-hover)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
1
+ :root{--bg-primary:#0a0a0b;--bg-secondary:#141416;--bg-tertiary:#1c1c1f;--bg-hover:#252528;--text-primary:#fafafa;--text-secondary:#a1a1aa;--text-muted:#71717a;--accent-primary:#0d9488;--accent-primary-hover:#14b8a6;--accent-success:#22c55e;--accent-error:#ef4444;--accent-warning:#f59e0b;--accent-tool:#3b82f6;--accent-llm:#a855f7;--border-color:#27272a;--border-radius:8px;--border-radius-sm:4px;--border-radius-lg:12px;--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", monospace;--font-size-xs:.75rem;--font-size-sm:.875rem;--font-size-base:1rem;--font-size-lg:1.125rem;--font-size-xl:1.25rem;--font-size-2xl:1.5rem;--font-size-3xl:2rem;--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;--space-5:1.25rem;--space-6:1.5rem;--space-8:2rem;--space-10:2.5rem;--space-12:3rem;--shadow-sm:0 1px 2px #0000004d;--shadow-md:0 4px 6px #0006;--shadow-lg:0 10px 15px #00000080;--transition-fast:.15s ease;--transition-base:.2s ease}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{width:100%;height:100%}body{font-family:var(--font-sans);font-size:var(--font-size-base);color:var(--text-primary);background-color:var(--bg-primary);-webkit-font-smoothing:antialiased;line-height:1.5}a{color:var(--accent-primary);text-decoration:none}a:hover{color:var(--accent-primary-hover)}button{cursor:pointer;font-family:inherit}input,select{font-family:inherit;font-size:inherit}code,pre{font-family:var(--font-mono)}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,[role=button]:focus-visible{outline:2px solid var(--accent-primary);outline-offset:2px}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:var(--border-color);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Iris — Agent Eval & Observability</title>
8
- <script type="module" crossorigin src="/assets/index-BTiYZFEQ.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-C9BwWthL.css">
8
+ <script type="module" crossorigin src="/assets/index-BNDB0-BX.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CAwaZNw5.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -1,15 +1,24 @@
1
1
  import { evalQuerySchema } from '../validation.js';
2
2
  export function registerEvaluationRoutes(router, storage) {
3
3
  router.get('/evaluations', async (req, res) => {
4
- const query = evalQuerySchema.parse(req.query);
5
- const result = await storage.queryEvalResults({
6
- eval_type: query.eval_type,
7
- passed: query.passed,
8
- since: query.since,
9
- until: query.until,
10
- limit: query.limit,
11
- offset: query.offset,
12
- });
13
- res.json(result);
4
+ try {
5
+ const query = evalQuerySchema.parse(req.query);
6
+ const result = await storage.queryEvalResults({
7
+ eval_type: query.eval_type,
8
+ passed: query.passed,
9
+ since: query.since,
10
+ until: query.until,
11
+ limit: query.limit,
12
+ offset: query.offset,
13
+ });
14
+ res.json(result);
15
+ }
16
+ catch (err) {
17
+ if (err instanceof Error && err.name === 'ZodError') {
18
+ res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
19
+ return;
20
+ }
21
+ throw err;
22
+ }
14
23
  });
15
24
  }
@@ -1,8 +1,17 @@
1
1
  import { summaryQuerySchema } from '../validation.js';
2
2
  export function registerSummaryRoutes(router, storage) {
3
3
  router.get('/summary', async (req, res) => {
4
- const query = summaryQuerySchema.parse(req.query);
5
- const summary = await storage.getDashboardSummary(query.hours);
6
- res.json(summary);
4
+ try {
5
+ const query = summaryQuerySchema.parse(req.query);
6
+ const summary = await storage.getDashboardSummary(query.hours);
7
+ res.json(summary);
8
+ }
9
+ catch (err) {
10
+ if (err instanceof Error && err.name === 'ZodError') {
11
+ res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
12
+ return;
13
+ }
14
+ throw err;
15
+ }
7
16
  });
8
17
  }
@@ -1,29 +1,47 @@
1
1
  import { traceQuerySchema } from '../validation.js';
2
2
  export function registerTraceRoutes(router, storage) {
3
3
  router.get('/traces', async (req, res) => {
4
- const query = traceQuerySchema.parse(req.query);
5
- const result = await storage.queryTraces({
6
- filter: {
7
- agent_name: query.agent_name,
8
- framework: query.framework,
9
- since: query.since,
10
- until: query.until,
11
- },
12
- limit: query.limit,
13
- offset: query.offset,
14
- sort_by: query.sort_by,
15
- sort_order: query.sort_order,
16
- });
17
- res.json(result);
4
+ try {
5
+ const query = traceQuerySchema.parse(req.query);
6
+ const result = await storage.queryTraces({
7
+ filter: {
8
+ agent_name: query.agent_name,
9
+ framework: query.framework,
10
+ since: query.since,
11
+ until: query.until,
12
+ },
13
+ limit: query.limit,
14
+ offset: query.offset,
15
+ sort_by: query.sort_by,
16
+ sort_order: query.sort_order,
17
+ });
18
+ res.json(result);
19
+ }
20
+ catch (err) {
21
+ if (err instanceof Error && err.name === 'ZodError') {
22
+ res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
23
+ return;
24
+ }
25
+ throw err;
26
+ }
18
27
  });
19
28
  router.get('/traces/:id', async (req, res) => {
20
- const trace = await storage.getTrace(req.params.id);
21
- if (!trace) {
22
- res.status(404).json({ error: 'Trace not found' });
23
- return;
29
+ try {
30
+ const trace = await storage.getTrace(req.params.id);
31
+ if (!trace) {
32
+ res.status(404).json({ error: 'Trace not found' });
33
+ return;
34
+ }
35
+ const spans = await storage.getSpansByTraceId(req.params.id);
36
+ const evals = await storage.getEvalsByTraceId(req.params.id);
37
+ res.json({ trace, spans, evals });
38
+ }
39
+ catch (err) {
40
+ if (err instanceof Error && err.name === 'ZodError') {
41
+ res.status(400).json({ error: 'Invalid query parameters', details: err.issues });
42
+ return;
43
+ }
44
+ throw err;
24
45
  }
25
- const spans = await storage.getSpansByTraceId(req.params.id);
26
- const evals = await storage.getEvalsByTraceId(req.params.id);
27
- res.json({ trace, spans, evals });
28
46
  });
29
47
  }
@@ -2,7 +2,8 @@ import type { EvalRule, EvalContext, EvalResult, EvalType, CustomRuleDefinition
2
2
  export declare class EvalEngine {
3
3
  private additionalRules;
4
4
  private threshold;
5
- constructor(threshold?: number);
5
+ private ruleThresholds?;
6
+ constructor(threshold?: number, ruleThresholds?: Record<string, unknown>);
6
7
  registerRule(evalType: EvalType, rule: EvalRule): void;
7
8
  evaluate(evalType: EvalType, context: EvalContext, customRules?: CustomRuleDefinition[]): EvalResult;
8
9
  }
@@ -3,8 +3,10 @@ import { generateEvalId } from '../utils/ids.js';
3
3
  export class EvalEngine {
4
4
  additionalRules = new Map();
5
5
  threshold;
6
- constructor(threshold = 0.7) {
6
+ ruleThresholds;
7
+ constructor(threshold = 0.7, ruleThresholds) {
7
8
  this.threshold = threshold;
9
+ this.ruleThresholds = ruleThresholds;
8
10
  }
9
11
  registerRule(evalType, rule) {
10
12
  const existing = this.additionalRules.get(evalType) ?? [];
@@ -12,6 +14,13 @@ export class EvalEngine {
12
14
  this.additionalRules.set(evalType, existing);
13
15
  }
14
16
  evaluate(evalType, context, customRules) {
17
+ // Merge system-level thresholds into customConfig (user-provided values take precedence)
18
+ if (this.ruleThresholds) {
19
+ context = {
20
+ ...context,
21
+ customConfig: { ...this.ruleThresholds, ...context.customConfig },
22
+ };
23
+ }
15
24
  let rules;
16
25
  if (evalType === 'custom' && customRules) {
17
26
  rules = customRules.map((def) => createCustomRule(def));
@@ -28,27 +37,70 @@ export class EvalEngine {
28
37
  eval_type: evalType,
29
38
  output_text: context.output,
30
39
  expected_text: context.expected,
31
- score: 1,
32
- passed: true,
40
+ score: 0,
41
+ passed: false,
33
42
  rule_results: [],
34
43
  suggestions: ['No rules configured for this eval type'],
44
+ rules_evaluated: 0,
45
+ rules_skipped: 0,
46
+ insufficient_data: true,
35
47
  };
36
48
  }
37
49
  const ruleResults = rules.map((rule) => rule.evaluate(context));
38
- const totalWeight = rules.reduce((sum, r) => sum + r.weight, 0);
39
- const weightedScore = rules.reduce((sum, rule, i) => {
50
+ // Partition into evaluated vs skipped
51
+ const evaluatedIndices = [];
52
+ const skippedIndices = [];
53
+ for (let i = 0; i < ruleResults.length; i++) {
54
+ if (ruleResults[i].skipped) {
55
+ skippedIndices.push(i);
56
+ }
57
+ else {
58
+ evaluatedIndices.push(i);
59
+ }
60
+ }
61
+ const rulesEvaluated = evaluatedIndices.length;
62
+ const rulesSkipped = skippedIndices.length;
63
+ // Handle "all rules skipped" — insufficient data
64
+ if (rulesEvaluated === 0) {
65
+ const skipMessages = ruleResults
66
+ .filter((r) => r.skipped)
67
+ .map((r) => `[${r.ruleName}] ${r.skipReason ?? r.message}`);
68
+ return {
69
+ id: generateEvalId(),
70
+ eval_type: evalType,
71
+ output_text: context.output,
72
+ expected_text: context.expected,
73
+ score: 0,
74
+ passed: false,
75
+ rule_results: ruleResults,
76
+ suggestions: [
77
+ 'Insufficient context to evaluate. Provide: expected, input, costUsd, or tokenUsage.',
78
+ ...skipMessages,
79
+ ],
80
+ rules_evaluated: 0,
81
+ rules_skipped: rulesSkipped,
82
+ insufficient_data: true,
83
+ };
84
+ }
85
+ // Weighted average across evaluated rules only (exclude skipped)
86
+ const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
87
+ const weightedScore = evaluatedIndices.reduce((sum, i) => {
40
88
  const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
41
- return sum + ruleScore * rule.weight;
89
+ return sum + ruleScore * rules[i].weight;
42
90
  }, 0);
43
91
  const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
44
92
  const score = Number.isFinite(rawScore) ? rawScore : 0;
45
93
  const passed = score >= this.threshold;
46
94
  const suggestions = [];
47
95
  for (const result of ruleResults) {
48
- if (!result.passed) {
96
+ if (!result.passed && !result.skipped) {
49
97
  suggestions.push(`[${result.ruleName}] ${result.message}`);
50
98
  }
51
99
  }
100
+ if (rulesSkipped > 0) {
101
+ const skippedNames = ruleResults.filter((r) => r.skipped).map((r) => r.ruleName);
102
+ suggestions.push(`${rulesSkipped} rule(s) skipped (missing context): ${skippedNames.join(', ')}`);
103
+ }
52
104
  return {
53
105
  id: generateEvalId(),
54
106
  eval_type: evalType,
@@ -58,6 +110,9 @@ export class EvalEngine {
58
110
  passed,
59
111
  rule_results: ruleResults,
60
112
  suggestions,
113
+ rules_evaluated: rulesEvaluated,
114
+ rules_skipped: rulesSkipped,
115
+ insufficient_data: false,
61
116
  };
62
117
  }
63
118
  }
@@ -4,7 +4,9 @@ export const minOutputLength = {
4
4
  evalType: 'completeness',
5
5
  weight: 1,
6
6
  evaluate(context) {
7
- const minLen = context.customConfig?.min_length ?? 10;
7
+ const minLen = context.customConfig?.min_output_length
8
+ ?? context.customConfig?.min_length
9
+ ?? 50;
8
10
  const len = context.output.length;
9
11
  const passed = len >= minLen;
10
12
  return {
@@ -36,7 +38,7 @@ export const sentenceCount = {
36
38
  evalType: 'completeness',
37
39
  weight: 0.5,
38
40
  evaluate(context) {
39
- const minSentences = context.customConfig?.min_sentences ?? 1;
41
+ const minSentences = context.customConfig?.min_sentences ?? 2;
40
42
  const sentences = context.output.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
41
43
  const passed = sentences >= minSentences;
42
44
  return {
@@ -54,7 +56,7 @@ export const expectedCoverage = {
54
56
  weight: 1.5,
55
57
  evaluate(context) {
56
58
  if (!context.expected) {
57
- return { ruleName: 'expected_coverage', passed: true, score: 1, message: 'No expected output provided skipped' };
59
+ return { ruleName: 'expected_coverage', passed: false, score: 0, message: 'No expected output provided', skipped: true, skipReason: 'context.expected not provided' };
58
60
  }
59
61
  const expectedWords = new Set(context.expected.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
60
62
  const outputWords = new Set(context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
@@ -4,8 +4,11 @@ export const costUnderThreshold = {
4
4
  evalType: 'cost',
5
5
  weight: 1,
6
6
  evaluate(context) {
7
+ if (context.costUsd === undefined || context.costUsd === null) {
8
+ return { ruleName: 'cost_under_threshold', passed: false, score: 0, message: 'Cost data not provided', skipped: true, skipReason: 'context.costUsd not provided' };
9
+ }
7
10
  const threshold = context.customConfig?.cost_threshold ?? 0.10;
8
- const cost = context.costUsd ?? 0;
11
+ const cost = context.costUsd;
9
12
  const passed = cost <= threshold;
10
13
  return {
11
14
  ruleName: 'cost_under_threshold',
@@ -26,7 +29,7 @@ export const tokenEfficiency = {
26
29
  const prompt = context.tokenUsage?.prompt_tokens;
27
30
  const completion = context.tokenUsage?.completion_tokens;
28
31
  if (prompt === undefined || completion === undefined || prompt === 0) {
29
- return { ruleName: 'token_efficiency', passed: true, score: 1, message: 'Token usage not provided skipped' };
32
+ return { ruleName: 'token_efficiency', passed: false, score: 0, message: 'Token usage not provided', skipped: true, skipReason: 'context.tokenUsage not provided' };
30
33
  }
31
34
  const ratio = completion / prompt;
32
35
  const maxRatio = context.customConfig?.max_token_ratio ?? 5;
@@ -58,6 +58,9 @@ export function createCustomRule(definition) {
58
58
  }
59
59
  case 'contains_keywords': {
60
60
  const keywords = definition.config.keywords;
61
+ if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
62
+ return { ruleName: definition.name, passed: false, score: 0, message: 'contains_keywords rule requires config.keywords (non-empty string array)' };
63
+ }
61
64
  const lower = context.output.toLowerCase();
62
65
  const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
63
66
  const ratio = found.length / keywords.length;
@@ -66,6 +69,9 @@ export function createCustomRule(definition) {
66
69
  }
67
70
  case 'excludes_keywords': {
68
71
  const keywords = definition.config.keywords;
72
+ if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
73
+ return { ruleName: definition.name, passed: false, score: 0, message: 'excludes_keywords rule requires config.keywords (non-empty string array)' };
74
+ }
69
75
  const lower = context.output.toLowerCase();
70
76
  const found = keywords.filter((k) => lower.includes(k.toLowerCase()));
71
77
  const passed = found.length === 0;
@@ -82,6 +88,9 @@ export function createCustomRule(definition) {
82
88
  }
83
89
  case 'cost_threshold': {
84
90
  const max = definition.config.max_cost;
91
+ if (max == null || max < 0) {
92
+ return { ruleName: definition.name, passed: false, score: 0, message: 'cost_threshold rule requires config.max_cost (non-negative number)' };
93
+ }
85
94
  const cost = context.costUsd ?? 0;
86
95
  const passed = cost <= max;
87
96
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? `Cost ($${cost}) within threshold ($${max})` : `Cost ($${cost}) exceeds threshold ($${max})` };
@@ -5,7 +5,7 @@ export const keywordOverlap = {
5
5
  weight: 1,
6
6
  evaluate(context) {
7
7
  if (!context.input) {
8
- return { ruleName: 'keyword_overlap', passed: true, score: 1, message: 'No input provided skipped' };
8
+ return { ruleName: 'keyword_overlap', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
9
9
  }
10
10
  const inputWords = new Set(context.input.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
11
11
  const outputWords = new Set(context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 2));
@@ -18,7 +18,8 @@ export const keywordOverlap = {
18
18
  overlap++;
19
19
  }
20
20
  const ratio = overlap / inputWords.size;
21
- const passed = ratio >= 0.2;
21
+ const threshold = context.customConfig?.keyword_overlap ?? 0.35;
22
+ const passed = ratio >= threshold;
22
23
  return {
23
24
  ruleName: 'keyword_overlap',
24
25
  passed,
@@ -29,6 +30,7 @@ export const keywordOverlap = {
29
30
  };
30
31
  const HALLUCINATION_MARKERS = [
31
32
  'as an ai',
33
+ 'as a language model',
32
34
  'i cannot',
33
35
  'i don\'t have access',
34
36
  'i apologize',
@@ -36,6 +38,14 @@ const HALLUCINATION_MARKERS = [
36
38
  'i must clarify',
37
39
  'it\'s important to note that i',
38
40
  'i should mention that as',
41
+ 'i\'m just an ai',
42
+ 'i don\'t actually',
43
+ 'i cannot provide',
44
+ 'i\'m unable to',
45
+ 'please note that i',
46
+ 'as a digital assistant',
47
+ 'i want to be transparent',
48
+ 'i need to be honest',
39
49
  ];
40
50
  export const noHallucinationMarkers = {
41
51
  name: 'no_hallucination_markers',
@@ -61,12 +71,12 @@ export const topicConsistency = {
61
71
  weight: 1,
62
72
  evaluate(context) {
63
73
  if (!context.input) {
64
- return { ruleName: 'topic_consistency', passed: true, score: 1, message: 'No input provided skipped' };
74
+ return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
65
75
  }
66
76
  const inputWords = context.input.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
67
77
  const outputWords = context.output.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
68
78
  if (inputWords.length === 0 || outputWords.length === 0) {
69
- return { ruleName: 'topic_consistency', passed: true, score: 1, message: 'Insufficient text for topic analysis' };
79
+ return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'Insufficient text for topic analysis', skipped: true, skipReason: 'input or output has no words > 3 chars' };
70
80
  }
71
81
  const inputSet = new Set(inputWords);
72
82
  let relevant = 0;
@@ -75,7 +85,8 @@ export const topicConsistency = {
75
85
  relevant++;
76
86
  }
77
87
  const ratio = relevant / outputWords.length;
78
- const passed = ratio >= 0.05;
88
+ const threshold = context.customConfig?.topic_consistency ?? 0.10;
89
+ const passed = ratio >= threshold;
79
90
  return {
80
91
  ruleName: 'topic_consistency',
81
92
  passed,
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ const { values } = parseArgs({
22
22
  });
23
23
  if (values.help) {
24
24
  process.stderr.write(`
25
- Iris — MCP-Native Agent Eval & Observability Server
25
+ Iris — MCP-Native Agent Eval Server
26
26
 
27
27
  Usage: iris-mcp [options]
28
28
 
@@ -55,6 +55,13 @@ async function main() {
55
55
  logger.info(`Storage initialized (${config.storage.type}: ${config.storage.path})`);
56
56
  const { mcpServer } = createIrisServer(config, storage);
57
57
  const httpServers = [];
58
+ // Run data retention cleanup on startup
59
+ if (config.retention.days > 0) {
60
+ const deleted = await storage.deleteTracesOlderThan(config.retention.days);
61
+ if (deleted > 0) {
62
+ logger.info(`Retention cleanup: deleted ${deleted} trace(s) older than ${config.retention.days} days`);
63
+ }
64
+ }
58
65
  if (config.transport.type === 'http') {
59
66
  const { transport, httpServer } = await createHttpTransport(mcpServer, config, logger);
60
67
  httpServers.push(httpServer);
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ export function createIrisServer(config, storage) {
7
7
  name: config.server.name,
8
8
  version: config.server.version,
9
9
  });
10
- const evalEngine = new EvalEngine(config.eval.defaultThreshold);
10
+ const evalEngine = new EvalEngine(config.eval.defaultThreshold, config.eval.ruleThresholds);
11
11
  registerAllTools(mcpServer, storage, evalEngine);
12
12
  registerAllResources(mcpServer, storage);
13
13
  return { mcpServer, evalEngine };
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const id = "002-eval-skip-fields";
3
+ export declare function up(db: Database.Database): void;
@@ -0,0 +1,8 @@
1
+ export const id = '002-eval-skip-fields';
2
+ export function up(db) {
3
+ db.exec(`
4
+ ALTER TABLE eval_results ADD COLUMN rules_evaluated INTEGER;
5
+ ALTER TABLE eval_results ADD COLUMN rules_skipped INTEGER;
6
+ ALTER TABLE eval_results ADD COLUMN insufficient_data INTEGER DEFAULT 0;
7
+ `);
8
+ }
@@ -0,0 +1,3 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const id = "003-eval-passed-index";
3
+ export declare function up(db: Database.Database): void;
@@ -0,0 +1,6 @@
1
+ export const id = '003-eval-passed-index';
2
+ export function up(db) {
3
+ db.exec(`
4
+ CREATE INDEX IF NOT EXISTS idx_eval_results_passed ON eval_results(passed);
5
+ `);
6
+ }
@@ -1,5 +1,7 @@
1
1
  import * as migration001 from './001-initial-schema.js';
2
- const migrations = [migration001];
2
+ import * as migration002 from './002-eval-skip-fields.js';
3
+ import * as migration003 from './003-eval-passed-index.js';
4
+ const migrations = [migration001, migration002, migration003];
3
5
  export function runMigrations(db) {
4
6
  db.exec(`
5
7
  CREATE TABLE IF NOT EXISTS _iris_migrations (
@@ -1,5 +1,7 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { runMigrations } from './migrations/index.js';
3
+ const ALLOWED_SORT_COLUMNS = new Set(['timestamp', 'latency_ms', 'cost_usd']);
4
+ const ALLOWED_SORT_ORDERS = new Set(['asc', 'desc']);
3
5
  export class SqliteAdapter {
4
6
  db;
5
7
  constructor(dbPath) {
@@ -15,16 +17,23 @@ export class SqliteAdapter {
15
17
  this.db.close();
16
18
  }
17
19
  async insertTrace(trace) {
18
- const stmt = this.db.prepare(`
20
+ const insertTraceStmt = this.db.prepare(`
19
21
  INSERT INTO traces (trace_id, agent_name, framework, input, output, tool_calls, latency_ms, token_usage, cost_usd, metadata, timestamp)
20
22
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
21
23
  `);
22
- stmt.run(trace.trace_id, trace.agent_name, trace.framework ?? null, trace.input ?? null, trace.output ?? null, trace.tool_calls ? JSON.stringify(trace.tool_calls) : null, trace.latency_ms ?? null, trace.token_usage ? JSON.stringify(trace.token_usage) : null, trace.cost_usd ?? null, trace.metadata ? JSON.stringify(trace.metadata) : null, trace.timestamp);
23
- if (trace.spans) {
24
- for (const span of trace.spans) {
25
- await this.insertSpan({ ...span, trace_id: trace.trace_id });
24
+ const insertSpanStmt = this.db.prepare(`
25
+ INSERT INTO spans (span_id, trace_id, parent_span_id, name, kind, status_code, status_message, start_time, end_time, attributes, events)
26
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
27
+ `);
28
+ const insertAll = this.db.transaction((t) => {
29
+ insertTraceStmt.run(t.trace_id, t.agent_name, t.framework ?? null, t.input ?? null, t.output ?? null, t.tool_calls ? JSON.stringify(t.tool_calls) : null, t.latency_ms ?? null, t.token_usage ? JSON.stringify(t.token_usage) : null, t.cost_usd ?? null, t.metadata ? JSON.stringify(t.metadata) : null, t.timestamp);
30
+ if (t.spans) {
31
+ for (const span of t.spans) {
32
+ insertSpanStmt.run(span.span_id, t.trace_id, span.parent_span_id ?? null, span.name, span.kind, span.status_code, span.status_message ?? null, span.start_time, span.end_time ?? null, span.attributes ? JSON.stringify(span.attributes) : null, span.events ? JSON.stringify(span.events) : null);
33
+ }
26
34
  }
27
- }
35
+ });
36
+ insertAll(trace);
28
37
  }
29
38
  async getTrace(traceId) {
30
39
  const row = this.db.prepare('SELECT * FROM traces WHERE trace_id = ?').get(traceId);
@@ -52,9 +61,23 @@ export class SqliteAdapter {
52
61
  conditions.push('timestamp <= ?');
53
62
  params.push(filter.until);
54
63
  }
64
+ if (filter?.min_score !== undefined) {
65
+ conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.trace_id = traces.trace_id AND e.score >= ?)');
66
+ params.push(filter.min_score);
67
+ }
68
+ if (filter?.max_score !== undefined) {
69
+ conditions.push('EXISTS (SELECT 1 FROM eval_results e WHERE e.trace_id = traces.trace_id AND e.score <= ?)');
70
+ params.push(filter.max_score);
71
+ }
55
72
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
56
73
  const sortBy = options.sort_by ?? 'timestamp';
57
74
  const sortOrder = options.sort_order ?? 'desc';
75
+ if (!ALLOWED_SORT_COLUMNS.has(sortBy)) {
76
+ throw new Error(`Invalid sort column: ${sortBy}`);
77
+ }
78
+ if (!ALLOWED_SORT_ORDERS.has(sortOrder)) {
79
+ throw new Error(`Invalid sort order: ${sortOrder}`);
80
+ }
58
81
  const limit = options.limit ?? 50;
59
82
  const offset = options.offset ?? 0;
60
83
  const countRow = this.db
@@ -84,9 +107,9 @@ export class SqliteAdapter {
84
107
  }
85
108
  async insertEvalResult(result) {
86
109
  this.db.prepare(`
87
- INSERT INTO eval_results (id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions)
88
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
89
- `).run(result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions));
110
+ INSERT INTO eval_results (id, trace_id, eval_type, output_text, expected_text, score, passed, rule_results, suggestions, rules_evaluated, rules_skipped, insufficient_data)
111
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
112
+ `).run(result.id, result.trace_id ?? null, result.eval_type, result.output_text, result.expected_text ?? null, result.score, result.passed ? 1 : 0, JSON.stringify(result.rule_results), JSON.stringify(result.suggestions), result.rules_evaluated ?? null, result.rules_skipped ?? null, result.insufficient_data ? 1 : 0);
90
113
  }
91
114
  async getEvalsByTraceId(traceId) {
92
115
  const rows = this.db
@@ -281,6 +304,8 @@ export class SqliteAdapter {
281
304
  for (const row of rows) {
282
305
  const rules = JSON.parse(row.rule_results);
283
306
  for (const r of rules) {
307
+ if (r.skipped)
308
+ continue;
284
309
  const entry = ruleMap.get(r.ruleName) ?? { totalRun: 0, failCount: 0 };
285
310
  entry.totalRun++;
286
311
  if (!r.passed)
@@ -394,6 +419,9 @@ export class SqliteAdapter {
394
419
  rule_results: JSON.parse(row.rule_results),
395
420
  suggestions: JSON.parse(row.suggestions),
396
421
  created_at: row.created_at,
422
+ rules_evaluated: row.rules_evaluated,
423
+ rules_skipped: row.rules_skipped,
424
+ insufficient_data: row.insufficient_data != null ? row.insufficient_data === 1 : undefined,
397
425
  };
398
426
  }
399
427
  }
@@ -50,6 +50,9 @@ export function registerEvaluateOutputTool(server, storage, evalEngine) {
50
50
  passed: result.passed,
51
51
  rule_results: result.rule_results,
52
52
  suggestions: result.suggestions,
53
+ rules_evaluated: result.rules_evaluated,
54
+ rules_skipped: result.rules_skipped,
55
+ insufficient_data: result.insufficient_data,
53
56
  }),
54
57
  },
55
58
  ],
@@ -18,6 +18,14 @@ export interface IrisConfig {
18
18
  };
19
19
  eval: {
20
20
  defaultThreshold: number;
21
+ ruleThresholds?: {
22
+ min_output_length?: number;
23
+ min_sentences?: number;
24
+ keyword_overlap?: number;
25
+ topic_consistency?: number;
26
+ cost_threshold?: number;
27
+ max_token_ratio?: number;
28
+ };
21
29
  };
22
30
  logging: {
23
31
  level: 'debug' | 'info' | 'warn' | 'error';
@@ -29,6 +29,8 @@ export interface EvalRuleResult {
29
29
  passed: boolean;
30
30
  score: number;
31
31
  message: string;
32
+ skipped?: boolean;
33
+ skipReason?: string;
32
34
  }
33
35
  export interface EvalResult {
34
36
  id: string;
@@ -41,6 +43,9 @@ export interface EvalResult {
41
43
  rule_results: EvalRuleResult[];
42
44
  suggestions: string[];
43
45
  created_at?: string;
46
+ rules_evaluated?: number;
47
+ rules_skipped?: number;
48
+ insufficient_data?: boolean;
44
49
  }
45
50
  export type CustomRuleType = 'regex_match' | 'regex_no_match' | 'min_length' | 'max_length' | 'contains_keywords' | 'excludes_keywords' | 'json_schema' | 'cost_threshold';
46
51
  export interface CustomRuleDefinition {