@iris-eval/mcp-server 0.8.2 → 0.10.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 (131) hide show
  1. package/README.md +9 -2
  2. package/dist/capabilities.d.ts +64 -0
  3. package/dist/capabilities.js +65 -0
  4. package/dist/config/defaults.js +17 -0
  5. package/dist/custom-rule-store.d.ts +4 -0
  6. package/dist/custom-rule-store.js +8 -3
  7. package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.js} +1 -1
  8. package/dist/dashboard/index.html +1 -1
  9. package/dist/dashboard/routes/capabilities.d.ts +3 -0
  10. package/dist/dashboard/routes/capabilities.js +11 -0
  11. package/dist/dashboard/routes/health.d.ts +5 -1
  12. package/dist/dashboard/routes/health.js +15 -3
  13. package/dist/dashboard/routes/rules.js +4 -1
  14. package/dist/dashboard/routes/traces.d.ts +3 -0
  15. package/dist/dashboard/routes/traces.js +11 -30
  16. package/dist/dashboard/seed-demo-data.js +1 -1
  17. package/dist/dashboard/server.d.ts +2 -0
  18. package/dist/dashboard/server.js +6 -2
  19. package/dist/eval/accuracy.d.ts +41 -0
  20. package/dist/eval/accuracy.js +97 -0
  21. package/dist/eval/citation-verify/verifier.d.ts +16 -1
  22. package/dist/eval/citation-verify/verifier.js +14 -4
  23. package/dist/eval/compose.d.ts +57 -0
  24. package/dist/eval/compose.js +179 -0
  25. package/dist/eval/criticality.d.ts +15 -1
  26. package/dist/eval/criticality.js +6 -0
  27. package/dist/eval/decision-moment.js +33 -4
  28. package/dist/eval/dormant.d.ts +4 -0
  29. package/dist/eval/dormant.js +22 -0
  30. package/dist/eval/engine.d.ts +6 -2
  31. package/dist/eval/engine.js +126 -12
  32. package/dist/eval/failure-classes.d.ts +8 -0
  33. package/dist/eval/failure-classes.js +18 -0
  34. package/dist/eval/llm-judge/evaluator.d.ts +30 -0
  35. package/dist/eval/llm-judge/evaluator.js +26 -2
  36. package/dist/eval/published-accuracy.d.ts +230 -0
  37. package/dist/eval/published-accuracy.js +86 -0
  38. package/dist/eval/questions.d.ts +12 -0
  39. package/dist/eval/questions.js +14 -0
  40. package/dist/eval/response-schema.d.ts +652 -0
  41. package/dist/eval/response-schema.js +130 -0
  42. package/dist/eval/response.d.ts +12 -0
  43. package/dist/eval/response.js +30 -0
  44. package/dist/eval/risk.d.ts +60 -0
  45. package/dist/eval/risk.js +187 -0
  46. package/dist/eval/rules/completeness.js +36 -1
  47. package/dist/eval/rules/cost.d.ts +2 -2
  48. package/dist/eval/rules/cost.js +50 -6
  49. package/dist/eval/rules/custom.d.ts +0 -12
  50. package/dist/eval/rules/custom.js +22 -0
  51. package/dist/eval/rules/relevance.js +23 -2
  52. package/dist/eval/rules/safety.d.ts +6 -2
  53. package/dist/eval/rules/safety.js +224 -51
  54. package/dist/eval/seeded-random.d.ts +4 -0
  55. package/dist/eval/seeded-random.js +36 -0
  56. package/dist/eval/stamp.d.ts +14 -0
  57. package/dist/eval/stamp.js +89 -0
  58. package/dist/eval/stats.d.ts +33 -0
  59. package/dist/eval/stats.js +109 -0
  60. package/dist/eval/text/checksums.d.ts +23 -0
  61. package/dist/eval/text/checksums.js +97 -0
  62. package/dist/eval/text/normalise.d.ts +30 -0
  63. package/dist/eval/text/normalise.js +265 -0
  64. package/dist/eval/text/sentences.d.ts +15 -0
  65. package/dist/eval/text/sentences.js +149 -0
  66. package/dist/eval/verdict.d.ts +34 -0
  67. package/dist/eval/verdict.js +131 -0
  68. package/dist/index.js +5 -28
  69. package/dist/instructions.d.ts +17 -0
  70. package/dist/instructions.js +53 -0
  71. package/dist/judge-enablement.d.ts +34 -0
  72. package/dist/judge-enablement.js +78 -0
  73. package/dist/judge-enablement.json +10 -0
  74. package/dist/preferences.d.ts +1 -1
  75. package/dist/prompts.d.ts +3 -0
  76. package/dist/prompts.js +29 -0
  77. package/dist/resources/index.d.ts +5 -2
  78. package/dist/resources/index.js +65 -5
  79. package/dist/resources/uris.d.ts +12 -0
  80. package/dist/resources/uris.js +24 -0
  81. package/dist/retention.d.ts +20 -0
  82. package/dist/retention.js +44 -0
  83. package/dist/self-test.d.ts +1 -0
  84. package/dist/self-test.js +17 -3
  85. package/dist/server.d.ts +10 -1
  86. package/dist/server.js +34 -7
  87. package/dist/storage/index.js +1 -1
  88. package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
  89. package/dist/storage/migrations/007-eval-provenance.js +30 -0
  90. package/dist/storage/migrations/index.js +24 -4
  91. package/dist/storage/sqlite-adapter.d.ts +26 -1
  92. package/dist/storage/sqlite-adapter.js +149 -15
  93. package/dist/tools/delete-rule.d.ts +8 -0
  94. package/dist/tools/delete-rule.js +30 -38
  95. package/dist/tools/delete-trace.d.ts +5 -0
  96. package/dist/tools/delete-trace.js +24 -27
  97. package/dist/tools/deploy-rule.d.ts +13 -1
  98. package/dist/tools/deploy-rule.js +37 -34
  99. package/dist/tools/describe.d.ts +20 -0
  100. package/dist/tools/describe.js +36 -0
  101. package/dist/tools/errors.d.ts +36 -0
  102. package/dist/tools/errors.js +134 -0
  103. package/dist/tools/evaluate-output.d.ts +8 -1
  104. package/dist/tools/evaluate-output.js +39 -60
  105. package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
  106. package/dist/tools/evaluate-with-llm-judge.js +124 -69
  107. package/dist/tools/get-traces.d.ts +9 -0
  108. package/dist/tools/get-traces.js +29 -28
  109. package/dist/tools/index.d.ts +8 -0
  110. package/dist/tools/index.js +22 -1
  111. package/dist/tools/list-rules.d.ts +13 -0
  112. package/dist/tools/list-rules.js +43 -46
  113. package/dist/tools/log-trace.d.ts +4 -0
  114. package/dist/tools/log-trace.js +31 -29
  115. package/dist/tools/respond.d.ts +42 -0
  116. package/dist/tools/respond.js +90 -0
  117. package/dist/tools/strict-input.js +1 -1
  118. package/dist/tools/trace-link.d.ts +2 -0
  119. package/dist/tools/trace-link.js +13 -2
  120. package/dist/tools/verify-citations.d.ts +18 -2
  121. package/dist/tools/verify-citations.js +122 -96
  122. package/dist/types/config.d.ts +44 -0
  123. package/dist/types/eval.d.ts +309 -0
  124. package/dist/types/eval.js +2 -1
  125. package/dist/types/query.d.ts +2 -0
  126. package/package.json +1 -1
  127. package/server.json +2 -2
  128. package/dist/resources/dashboard-summary.d.ts +0 -3
  129. package/dist/resources/dashboard-summary.js +0 -16
  130. package/dist/resources/trace-detail.d.ts +0 -3
  131. package/dist/resources/trace-detail.js +0 -30
@@ -3,6 +3,9 @@ import { generateTraceId, generateSpanId } from '../utils/ids.js';
3
3
  import { LOCAL_TENANT } from '../types/tenant.js';
4
4
  import { bestEffortExport } from '../otel/lazy.js';
5
5
  import { strictInput, strictNested } from './strict-input.js';
6
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
7
+ import { guarded, respond } from './respond.js';
8
+ import { traceUri } from '../resources/uris.js';
6
9
  /*
7
10
  * The tool-call record — one entry of `tool_calls[]`.
8
11
  *
@@ -58,47 +61,51 @@ export const logTraceInputShape = {
58
61
  framework: z.string().optional().describe('Agent framework identifier (e.g., langchain, autogen, custom)'),
59
62
  input: z.string().optional().describe('Agent input text — the user prompt or upstream input that produced this output'),
60
63
  output: z.string().optional().describe('Agent output text — what the agent produced (pass to evaluate_output for scoring)'),
61
- tool_calls: z.array(toolCallSchema).optional().describe('Tool calls made during execution (per-call latency, errors, input/output)'),
64
+ tool_calls: z.array(toolCallSchema).optional().describe('Tool calls made during execution, in order, each { tool_name, input?, output?, latency_ms?, error? } — what the trajectory rules judge; evaluate_output reuses them when given this trace_id'),
62
65
  latency_ms: z.number().optional().describe('Total execution time in milliseconds (end-to-end agent latency)'),
63
66
  token_usage: TokenUsageSchema.optional().describe('Token usage breakdown (prompt/completion/total — used for cost analysis)'),
64
67
  cost_usd: z.number().optional().describe('Total cost in USD — overrides per-span aggregation when provided (treated as authoritative)'),
65
68
  metadata: z.record(z.string(), z.unknown()).optional().describe('Opaque key-value tags (e.g. {requestId, userId, env}) — queryable in dashboard, not via get_traces filters'),
66
- spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events)'),
69
+ spans: z.array(SpanSchema).optional().describe('Detailed execution spans (hierarchical span tree with timings, attributes, events); a span without start_time takes the trace timestamp'),
67
70
  timestamp: z.string().optional().describe('Trace timestamp (ISO 8601); defaults to now() when omitted'),
68
71
  };
72
+ export const logTraceOutputSchema = z.looseObject({
73
+ trace_id: z.string().describe('the stored trace id, 32 hex — pass it to evaluate_output, get_traces or delete_trace'),
74
+ status: z.literal('stored').describe('always "stored" on success'),
75
+ });
69
76
  export function registerLogTraceTool(server, storage) {
70
77
  server.registerTool('log_trace', {
71
78
  title: 'Log Trace',
72
- description: [
73
- 'Persist a single agent execution trace (input, output, spans, tool calls, cost, latency, token usage).',
74
- '',
75
- 'Sibling tools evaluate_output runs heuristic scoring on the trace; evaluate_with_llm_judge runs semantic LLM-based scoring; verify_citations checks citation grounding; get_traces queries stored traces; delete_trace removes a single trace; list_rules / deploy_rule / delete_rule manage custom evaluation rules. log_trace is the WRITE path that records executions; everything else reads, scores, or manages around it.',
76
- '',
77
- 'Behavior. Writes one row to Iris storage (SQLite). When IRIS_OTEL_ENDPOINT is set, ALSO fires a best-effort async export to the configured OTLP/HTTP collector (Jaeger, Tempo, Datadog OTLP, OTEL Collector). The OTel export is fire-and-forget — its success does not affect the tool response; failures are logged but the trace is still stored locally. No authentication in stdio mode. HTTP mode requires a Bearer token ONLY when --api-key / IRIS_API_KEY is set (recommended); with no key configured the auth middleware is a pass-through and writes are unauthenticated — a default HTTP server is protected by its loopback bind (127.0.0.1) and Origin validation, not by a credential. Rate-limited to 20 req/min on HTTP MCP, unlimited on stdio. Not idempotent: each call mints a fresh trace_id, so resubmitting the same payload creates a duplicate trace.',
78
- '',
79
- 'Output shape. Returns a JSON string: `{ "trace_id": "<32-hex>", "status": "stored" }`. The trace_id is the key you pass to evaluate_output or get_traces afterwards.',
80
- '',
81
- 'Use when you want to record an agent execution for later evaluation, analysis, or audit. Call it AFTER the agent has produced output; call evaluate_output afterwards to score it; call get_traces to query historical traces. Store rich context: spans (span tree), tool_calls (which tools were invoked with latency/errors), token_usage, cost_usd, metadata (arbitrary key-value). All optional except agent_name.',
82
- '',
83
- 'Don\'t use when you only need a transient log (use console logging). Don\'t use to update an existing trace — there is no update path (traces are immutable once stored).',
84
- '',
85
- 'Parameters. agent_name is required; everything else is optional. token_usage and cost_usd are summary fields — if you ALSO pass spans with per-tool-call costs, the summary fields are treated as authoritative (no auto-aggregation). spans without an explicit start_time fall back to the trace timestamp; spans with an end_time get a duration_ms derived. metadata is opaque key-value (queryable in the dashboard, not via get_traces filters). tool_calls record per-tool latency + errors; missing latency_ms means "not reported," not "zero." Defaults: span.kind="INTERNAL", span.status_code="UNSET", timestamp=now() if omitted.',
86
- '',
87
- 'Error modes. Throws on missing agent_name. Throws on malformed span or tool_call objects (Zod rejects). Returns 500 on storage failure (disk full, DB locked). Never blocks on the agent — the local write is synchronous and the OTel export is asynchronous.',
88
- ].join('\n'),
79
+ description: describeTool({
80
+ summary: 'Store one agent execution input, output, tool calls, spans, cost, latency, token usage — and get the trace_id every later call keys on.',
81
+ does: 'Writes one trace row to local SQLite and mints a fresh trace_id; nothing is deduplicated, so resubmitting the same payload stores a second trace. ' +
82
+ 'Only agent_name is required. Store what you have: tool_calls so the trajectory rules can later judge what the agent did, cost_usd and token_usage so the cost rules can, input and output so everything else can. ' +
83
+ 'When IRIS_OTEL_ENDPOINT is set the trace is also exported to that collector, best-effort and asynchronous; the local write never waits on it. ' +
84
+ 'Traces are immutable: there is no update path. In stdio mode nothing authenticates the caller; over HTTP a Bearer token is required only when an API key is configured.',
85
+ whenNot: 'For a transient log line (use your logger). To score an output: log first, then call evaluate_output with the trace_id, which also lets it reuse the stored tool_calls. To change a stored trace: delete_trace and log again.',
86
+ returns: logTraceOutputSchema,
87
+ errors: 'IRIS_STORAGE_ERROR when the database cannot be written. An unknown argument or a malformed span or tool_calls entry is refused before the handler runs, naming the valid keys. ' +
88
+ ERROR_ENVELOPE_SENTENCE,
89
+ siblings: {
90
+ evaluate_output: 'score the stored output',
91
+ get_traces: 'query what was logged',
92
+ delete_trace: 'remove one trace',
93
+ },
94
+ }),
89
95
  // Strict at the MCP boundary (unknown args rejected, not stripped).
90
96
  // The dashboard's HTTP ingest builds its own — equally strict —
91
97
  // schema FROM this shape (dashboard/validation.ts): a client-supplied
92
98
  // trace_id is rejected there with a 400 whose message says the server
93
99
  // mints it, exactly as this tool mints its own in the handler below.
94
100
  inputSchema: strictInput(logTraceInputShape),
101
+ outputSchema: logTraceOutputSchema,
95
102
  annotations: {
96
103
  readOnlyHint: false, // Writes a row to storage
97
104
  destructiveHint: false, // Creates new data; doesn't overwrite or delete
98
105
  idempotentHint: false, // Each call mints a fresh trace_id; duplicate payloads produce distinct traces
99
106
  openWorldHint: false, // Local storage first. When IRIS_OTEL_ENDPOINT is set a best-effort async OTel export runs but is non-blocking (tool succeeds even if export fails).
100
107
  },
101
- }, async (args) => {
108
+ }, guarded(async (args) => {
102
109
  const traceId = generateTraceId();
103
110
  const timestamp = args.timestamp ?? new Date().toISOString();
104
111
  const trace = {
@@ -128,13 +135,8 @@ export function registerLogTraceTool(server, storage) {
128
135
  // eslint-disable-next-line no-console
129
136
  console.warn(`[iris.otel] ${err.message}`);
130
137
  });
131
- return {
132
- content: [
133
- {
134
- type: 'text',
135
- text: JSON.stringify({ trace_id: traceId, status: 'stored' }),
136
- },
137
- ],
138
- };
139
- });
138
+ return respond(logTraceOutputSchema, { trace_id: traceId, status: 'stored' }, [
139
+ { uri: traceUri(traceId), name: `trace ${traceId}`, description: 'The stored trace with its spans and, later, its evaluations' },
140
+ ]);
141
+ }));
140
142
  }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
+ import { IrisError } from './errors.js';
4
+ export declare const CAPABILITIES_URI = "iris://capabilities";
5
+ export interface ResourceLink {
6
+ uri: string;
7
+ name: string;
8
+ description?: string;
9
+ }
10
+ export declare const CAPABILITIES_LINK: ResourceLink;
11
+ export declare function respond<S extends z.ZodType>(schema: S, payload: object, links?: ResourceLink[]): CallToolResult;
12
+ export declare const errorEnvelopeSchema: z.ZodObject<{
13
+ error: z.ZodObject<{
14
+ code: z.ZodEnum<{
15
+ IRIS_INVALID_ARGUMENT: "IRIS_INVALID_ARGUMENT";
16
+ IRIS_UNKNOWN_TRACE: "IRIS_UNKNOWN_TRACE";
17
+ IRIS_DUPLICATE_RULE: "IRIS_DUPLICATE_RULE";
18
+ IRIS_INVALID_RULE_CONFIG: "IRIS_INVALID_RULE_CONFIG";
19
+ IRIS_JUDGE_NOT_ENABLED: "IRIS_JUDGE_NOT_ENABLED";
20
+ IRIS_JUDGE_UNKNOWN_MODEL: "IRIS_JUDGE_UNKNOWN_MODEL";
21
+ IRIS_BUDGET_EXCEEDED: "IRIS_BUDGET_EXCEEDED";
22
+ IRIS_PROVIDER_ERROR: "IRIS_PROVIDER_ERROR";
23
+ IRIS_JUDGE_FAILED: "IRIS_JUDGE_FAILED";
24
+ IRIS_STORAGE_ERROR: "IRIS_STORAGE_ERROR";
25
+ IRIS_INTERNAL_ERROR: "IRIS_INTERNAL_ERROR";
26
+ }>;
27
+ message: z.ZodString;
28
+ recovery: z.ZodArray<z.ZodString>;
29
+ retryable: z.ZodBoolean;
30
+ field: z.ZodOptional<z.ZodString>;
31
+ valid: z.ZodOptional<z.ZodArray<z.ZodString>>;
32
+ see: z.ZodOptional<z.ZodString>;
33
+ kind: z.ZodOptional<z.ZodString>;
34
+ retryAfterMs: z.ZodOptional<z.ZodNumber>;
35
+ }, z.core.$loose>;
36
+ }, z.core.$loose>;
37
+ export type ErrorEnvelope = z.infer<typeof errorEnvelopeSchema>;
38
+ export declare function errorResult(err: IrisError): CallToolResult;
39
+ /** Wrap a handler so every failure returns an envelope instead of a flattened line. */
40
+ export declare function guarded<A extends unknown[]>(fn: (...args: A) => Promise<CallToolResult> | CallToolResult): (...args: A) => Promise<CallToolResult>;
41
+ /** Links for what an evaluation created: the evaluation, and the trace when linked. */
42
+ export declare function evaluationLinks(evalId: string, traceId?: string): ResourceLink[];
@@ -0,0 +1,90 @@
1
+ /*
2
+ * One way to answer a tool call.
3
+ *
4
+ * `respond` parses the payload through the tool's own output schema
5
+ * BEFORE serialising it — a field the schema does not describe fails a
6
+ * test, not a user — and emits the same object twice: as the text a
7
+ * client without structured-content support reads, and as
8
+ * `structuredContent` for one that has it. Beside the payload go
9
+ * `resource_link` items for what the call created and what explains its
10
+ * limits, so a client can follow them with resources/read instead of
11
+ * guessing a URI.
12
+ *
13
+ * `errorResult` is the failure shape: the IrisError envelope as the text
14
+ * and as structuredContent, `isError: true`, and a link to
15
+ * iris://capabilities. `guarded` wraps a handler so nothing thrown inside
16
+ * it reaches the SDK's flattener.
17
+ */
18
+ import { z } from 'zod';
19
+ import { ERROR_CODE_CATALOGUE, toIrisError } from './errors.js';
20
+ import { CAPABILITIES_RESOURCE_URI, evaluationUri, traceUri } from '../resources/uris.js';
21
+ export const CAPABILITIES_URI = CAPABILITIES_RESOURCE_URI;
22
+ export const CAPABILITIES_LINK = {
23
+ uri: CAPABILITIES_URI,
24
+ name: 'capabilities',
25
+ description: 'What this server can judge, what each rule needs, judge state, limits, tools and resources',
26
+ };
27
+ const linkItem = (l) => ({
28
+ type: 'resource_link',
29
+ uri: l.uri,
30
+ name: l.name,
31
+ ...(l.description ? { description: l.description } : {}),
32
+ mimeType: 'application/json',
33
+ });
34
+ /** JSON round-trip: what the text carries is exactly what structuredContent carries. */
35
+ function normalise(payload) {
36
+ return JSON.parse(JSON.stringify(payload));
37
+ }
38
+ export function respond(schema, payload, links = []) {
39
+ const body = normalise(payload);
40
+ const parsed = schema.safeParse(body);
41
+ if (!parsed.success) {
42
+ // A programming error: the tool built a response its own schema does
43
+ // not describe. Loud on purpose — the drift-lock tests catch it.
44
+ const issues = parsed.error.issues.slice(0, 3).map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`);
45
+ throw new Error(`response does not match the tool's output schema: ${issues.join('; ')}`);
46
+ }
47
+ return {
48
+ content: [{ type: 'text', text: JSON.stringify(body) }, ...links.map(linkItem)],
49
+ structuredContent: body,
50
+ };
51
+ }
52
+ export const errorEnvelopeSchema = z.looseObject({
53
+ error: z.looseObject({
54
+ code: z.enum(ERROR_CODE_CATALOGUE),
55
+ message: z.string(),
56
+ recovery: z.array(z.string()),
57
+ retryable: z.boolean(),
58
+ field: z.string().optional(),
59
+ valid: z.array(z.string()).optional(),
60
+ see: z.string().optional(),
61
+ kind: z.string().optional(),
62
+ retryAfterMs: z.number().optional(),
63
+ }),
64
+ });
65
+ export function errorResult(err) {
66
+ const body = normalise({ error: err.envelope });
67
+ return {
68
+ content: [{ type: 'text', text: JSON.stringify(body) }, linkItem(CAPABILITIES_LINK)],
69
+ structuredContent: body,
70
+ isError: true,
71
+ };
72
+ }
73
+ /** Wrap a handler so every failure returns an envelope instead of a flattened line. */
74
+ export function guarded(fn) {
75
+ return async (...args) => {
76
+ try {
77
+ return await fn(...args);
78
+ }
79
+ catch (err) {
80
+ return errorResult(toIrisError(err));
81
+ }
82
+ };
83
+ }
84
+ /** Links for what an evaluation created: the evaluation, and the trace when linked. */
85
+ export function evaluationLinks(evalId, traceId) {
86
+ const links = [{ uri: evaluationUri(evalId), name: `evaluation ${evalId}`, description: 'The stored evaluation, as every reader sees it' }];
87
+ if (traceId)
88
+ links.push({ uri: traceUri(traceId), name: `trace ${traceId}`, description: 'The trace this evaluation is linked to, with its spans and every evaluation' });
89
+ return links;
90
+ }
@@ -29,7 +29,7 @@ export function strictInput(shape) {
29
29
  `Valid arguments: ${validKeys}. ` +
30
30
  'Unknown arguments are rejected rather than silently ignored, so a misspelled ' +
31
31
  'argument name cannot change what gets evaluated — check the spelling against ' +
32
- "the tool's input schema and retry."
32
+ "the tool's input schema and retry. Code IRIS_INVALID_ARGUMENT."
33
33
  : undefined,
34
34
  });
35
35
  }
@@ -11,6 +11,8 @@ export declare function unknownTraceMessage(traceId: string): string;
11
11
  * existence check had to load the row anyway. Fetching it twice would be
12
12
  * two reads for one fact — and two chances for them to disagree.
13
13
  */
14
+ /** The IRIS_UNKNOWN_TRACE error, built once for both the pre-check and the insert race. */
15
+ export declare function unknownTraceError(traceId: string): import("./errors.js").IrisError;
14
16
  export declare function getTraceOrThrow(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<Trace>;
15
17
  export declare function assertTraceExists(storage: IStorageAdapter, tenantId: TenantId, traceId: string): Promise<void>;
16
18
  /** insertEvalResult with the foreign-key race translated into the same clear message. */
@@ -1,3 +1,4 @@
1
+ import { irisError } from './errors.js';
1
2
  /*
2
3
  * Linking an evaluation to a trace that does not exist.
3
4
  *
@@ -26,10 +27,20 @@ export function unknownTraceMessage(traceId) {
26
27
  * existence check had to load the row anyway. Fetching it twice would be
27
28
  * two reads for one fact — and two chances for them to disagree.
28
29
  */
30
+ /** The IRIS_UNKNOWN_TRACE error, built once for both the pre-check and the insert race. */
31
+ export function unknownTraceError(traceId) {
32
+ return irisError('IRIS_UNKNOWN_TRACE', unknownTraceMessage(traceId), {
33
+ field: 'trace_id',
34
+ recovery: [
35
+ 'Pass the trace_id that log_trace returned, or one listed by get_traces.',
36
+ 'Or omit trace_id to store an unlinked evaluation.',
37
+ ],
38
+ });
39
+ }
29
40
  export async function getTraceOrThrow(storage, tenantId, traceId) {
30
41
  const trace = await storage.getTrace(tenantId, traceId);
31
42
  if (!trace)
32
- throw new Error(unknownTraceMessage(traceId));
43
+ throw unknownTraceError(traceId);
33
44
  return trace;
34
45
  }
35
46
  export async function assertTraceExists(storage, tenantId, traceId) {
@@ -44,7 +55,7 @@ export async function insertLinkedEvalResult(storage, tenantId, result) {
44
55
  const code = err.code;
45
56
  const message = err instanceof Error ? err.message : String(err);
46
57
  if (result.trace_id && (code === 'SQLITE_CONSTRAINT_FOREIGNKEY' || /FOREIGN KEY constraint failed/i.test(message))) {
47
- throw new Error(unknownTraceMessage(result.trace_id));
58
+ throw unknownTraceError(result.trace_id);
48
59
  }
49
60
  throw err;
50
61
  }
@@ -1,3 +1,4 @@
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';
3
4
  /**
@@ -5,8 +6,8 @@ import type { IStorageAdapter } from '../types/query.js';
5
6
  * was nothing to judge (no citations, none resolved). It is NOT the honest
6
7
  * answer when citations resolved and the judge then failed on every one —
7
8
  * a wrong API key, a model the provider refused, a parse failure — because
8
- * the caller reads "passed" and ships. That case is an error naming the
9
- * cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
9
+ * the caller reads "passed" and ships. That case is IRIS_JUDGE_FAILED
10
+ * naming the cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
10
11
  */
11
12
  export declare function assertJudgeRan(result: {
12
13
  totalResolved: number;
@@ -19,4 +20,19 @@ export declare function assertJudgeRan(result: {
19
20
  };
20
21
  }>;
21
22
  }): void;
23
+ export declare const verifyCitationsOutputSchema: z.ZodObject<{
24
+ id: z.ZodString;
25
+ trace_id: z.ZodOptional<z.ZodString>;
26
+ overall_score: z.ZodNullable<z.ZodNumber>;
27
+ passed: z.ZodNullable<z.ZodBoolean>;
28
+ total_unsupported: z.ZodNumber;
29
+ total_citations_found: z.ZodNumber;
30
+ total_resolved: z.ZodNumber;
31
+ total_judged: z.ZodNumber;
32
+ total_supported: z.ZodNumber;
33
+ total_cost_usd: z.ZodNumber;
34
+ citations: z.ZodArray<z.ZodObject<{
35
+ resolve_status: z.ZodString;
36
+ }, z.core.$loose>>;
37
+ }, z.core.$loose>;
22
38
  export declare function registerVerifyCitationsTool(server: McpServer, storage: IStorageAdapter): void;
@@ -1,41 +1,31 @@
1
1
  import { z } from 'zod';
2
2
  import { LOCAL_TENANT } from '../types/tenant.js';
3
3
  import { verifyCitations } from '../eval/citation-verify/verifier.js';
4
- import { findPricing } from '../eval/llm-judge/pricing.js';
5
4
  import { generateEvalId } from '../utils/ids.js';
5
+ import { JUDGE_KEY_VARS } from '../judge-enablement.js';
6
6
  import { strictInput } from './strict-input.js';
7
7
  import { assertTraceExists, insertLinkedEvalResult } from './trace-link.js';
8
+ import { inferProvider, resolveApiKey } from './evaluate-with-llm-judge.js';
9
+ import { describeTool, ERROR_ENVELOPE_SENTENCE } from './describe.js';
10
+ import { irisError } from './errors.js';
11
+ import { evaluationLinks, guarded, respond } from './respond.js';
8
12
  const inputSchema = {
9
13
  output: z.string().min(1).describe('The agent output containing citations to verify'),
10
14
  model: z
11
15
  .string()
12
- .describe('Judge model for per-citation verification. Supported: anthropic = claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5-20251001; openai = gpt-4o | gpt-4o-mini | o1-mini.'),
16
+ .describe('Judge model for per-citation verification. Supported: anthropic = claude-opus-4-7 | claude-sonnet-4-6 | claude-haiku-4-5 | claude-haiku-4-5-20251001; openai = gpt-4o | gpt-4o-mini | o1-mini.'),
13
17
  provider: z.enum(['anthropic', 'openai']).optional().describe('Auto-detected from model when omitted'),
14
18
  allow_fetch: z.boolean().optional().describe('Permit outbound HTTP to resolve URLs/DOIs. Defaults to IRIS_CITATION_ALLOW_FETCH=1; false otherwise. SSRF-guarded regardless.'),
15
19
  domain_allowlist: z
16
20
  .array(z.string())
17
21
  .optional()
18
22
  .describe('Restrict fetches to hostnames in this list (suffix match allowed). Merged with IRIS_CITATION_DOMAINS env.'),
19
- max_cost_usd_total: z.number().positive().optional().describe('Cap TOTAL judge cost across all citations in this call; default $1.00'),
20
- max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped); default 20'),
23
+ max_cost_usd_total: z.number().positive().optional().describe('Cap TOTAL judge cost across all citations in this call; default 1.00 USD — the pipeline stops when the next call would exceed it'),
24
+ max_citations: z.number().int().positive().max(50).optional().describe('Max citations to verify (extras skipped, not errored); default 20, at most 50'),
21
25
  per_source_timeout_ms: z.number().int().positive().optional().describe('Per-URL fetch timeout; default 10_000'),
22
26
  per_source_max_bytes: z.number().int().positive().optional().describe('Per-URL body cap; default 5MB'),
23
27
  trace_id: z.string().optional().describe('Link verification result to a stored trace (id from log_trace / get_traces); an unknown id is rejected before any fetch or judge call'),
24
28
  };
25
- function inferProvider(model) {
26
- const pricing = findPricing(model);
27
- if (!pricing) {
28
- throw new Error(`Unknown model "${model}". Provider cannot be inferred. Supported models: src/eval/llm-judge/pricing.ts.`);
29
- }
30
- return pricing.provider;
31
- }
32
- function resolveApiKey(provider) {
33
- const key = provider === 'anthropic' ? process.env.IRIS_ANTHROPIC_API_KEY : process.env.IRIS_OPENAI_API_KEY;
34
- if (!key) {
35
- throw new Error(`${provider === 'anthropic' ? 'Anthropic' : 'OpenAI'} judge requires IRIS_${provider === 'anthropic' ? 'ANTHROPIC' : 'OPENAI'}_API_KEY for verify_citations.`);
36
- }
37
- return key;
38
- }
39
29
  function resolveAllowFetch(paramValue) {
40
30
  if (paramValue !== undefined)
41
31
  return paramValue;
@@ -54,8 +44,8 @@ function resolveDomainAllowlist(paramValue) {
54
44
  * was nothing to judge (no citations, none resolved). It is NOT the honest
55
45
  * answer when citations resolved and the judge then failed on every one —
56
46
  * a wrong API key, a model the provider refused, a parse failure — because
57
- * the caller reads "passed" and ships. That case is an error naming the
58
- * cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
47
+ * the caller reads "passed" and ships. That case is IRIS_JUDGE_FAILED
48
+ * naming the cause; nothing is stored. (v0.6.0 acceptance pass, observation 3.)
59
49
  */
60
50
  export function assertJudgeRan(result) {
61
51
  if (result.totalResolved === 0 || result.totalJudged > 0)
@@ -65,39 +55,63 @@ export function assertJudgeRan(result) {
65
55
  return;
66
56
  const kinds = [...new Set(judgeFailures.map((c) => c.resolveError.kind))].join(', ');
67
57
  const first = judgeFailures[0].resolveError.message;
68
- throw new Error(`verify_citations could not judge any of the ${result.totalResolved} resolved citation(s): the judge failed on every one (${kinds}). ` +
69
- `Nothing was verified and nothing was stored, so there is no verdict. First error: ${first}`);
58
+ throw irisError('IRIS_JUDGE_FAILED', `verify_citations could not judge any of the ${result.totalResolved} resolved citation(s): the judge failed on every one (${kinds}). ` +
59
+ `Nothing was verified and nothing was stored, so there is no verdict. First error: ${first}`, {
60
+ retryable: /timeout|rate_limit|server_error/.test(kinds),
61
+ recovery: [
62
+ 'Check the key and the model: a refused key or an unknown model fails every citation the same way.',
63
+ 'Retry when the kind is a timeout, a rate limit or a provider server error.',
64
+ 'Raise max_cost_usd_total when the kind is cost_cap_reached.',
65
+ ],
66
+ });
70
67
  }
68
+ export const verifyCitationsOutputSchema = z.looseObject({
69
+ id: z.string().describe('the evaluation id; read it back at iris://evaluations/{id}'),
70
+ trace_id: z.string().optional().describe('the linked trace, when one was named'),
71
+ overall_score: z.number().nullable().describe('supported / judged; null when nothing was judged'),
72
+ passed: z
73
+ .boolean()
74
+ .nullable()
75
+ .describe('true when every judged citation was supported; false when any judged citation was not; NULL when nothing was judged — no verdict, because nothing was verified. Until 0.10.0 that last case returned true.'),
76
+ total_unsupported: z.number().int().describe('judged citations the judge ruled unsupported — the number the verdict turns on'),
77
+ total_citations_found: z.number().int().describe('citations extracted from the output'),
78
+ total_resolved: z.number().int().describe('citations whose source was fetched'),
79
+ total_judged: z.number().int().describe('citations the judge ruled on'),
80
+ total_supported: z.number().int().describe('citations the judge found supported'),
81
+ total_cost_usd: z.number().describe('the spend across every judge call'),
82
+ citations: z.array(z.looseObject({ resolve_status: z.string() })).describe('per citation: the citation (raw, kind, identifier, offsets), resolve_status ok | skipped | error, resolve_error, source (url, status, content_type, bytes_fetched, truncated), judge (supported, confidence, rationale, cost_usd, latency_ms, tokens)'),
83
+ });
71
84
  export function registerVerifyCitationsTool(server, storage) {
72
85
  server.registerTool('verify_citations', {
73
86
  title: 'Verify Citations',
74
- description: [
75
- 'Extract citations from agent output, fetch the cited sources, and use an LLM judge to check whether each source supports the claim in context. Returns per-citation verdicts + an overall support ratio.',
76
- '',
77
- 'Sibling tools evaluate_with_llm_judge runs general semantic scoring (accuracy, helpfulness, correctness, faithfulness); this tool is specifically for citation grounding (does the cited source actually support the claim). evaluate_output\'s no_hallucination_markers heuristic detects FABRICATED-looking citations cheaply (free, no fetch); this tool resolves and verifies them (paid, opt-in fetch, SSRF-guarded). log_trace / get_traces handle trace I/O. verify_citations is the GROUNDING-CHECK path narrowest in scope, deepest in rigor.',
78
- '',
79
- 'Behavior. Three-phase pipeline: (1) regex extraction of [N] numbered refs, (Author, Year) parentheticals, bare URLs, and DOIs (in-process, no network); (2) SSRF-guarded fetch of URL + DOI citations, with scheme allowlist, private/link-local/cloud-metadata IP blocking, optional domain allowlist (IRIS_CITATION_DOMAINS), 10s timeout, 5MB body cap, manual redirect chase (max 3, re-checked), in-process LRU cache; (3) per-citation LLM judge call asking "does this source support this claim?" with a 256-token verdict. Opt-in via allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — Iris refuses outbound HTTP by default. Cost-capped across the entire call by max_cost_usd_total (default $1.00) the pipeline stops when the cap would be exceeded. Rate-limited to 20 req/min on HTTP MCP. Writes one eval_result row tagged with per-citation provenance.',
80
- '',
81
- 'Output shape. Returns JSON: `{ "id": "<uuid>", "overall_score": 0..1|null, "passed": boolean, "total_citations_found": number, "total_resolved": number, "total_judged": number, "total_supported": number, "total_cost_usd": number, "citations": [{ "citation": { "raw", "kind", "identifier", "offset_start", "offset_end" }, "resolve_status": "ok"|"skipped"|"error", "resolve_error"?, "source"?: { "url", "status", "content_type", "bytes_fetched", "truncated" }, "judge"?: { "supported", "confidence", "rationale", "cost_usd", "latency_ms", "input_tokens", "output_tokens" } }] }`. `overall_score = supported / judged`; `null` when nothing was judged (no resolvable citations, or every judge call failed). Infrastructure failures (cost cap, judge timeout/error, malformed verdict) leave a citation resolved-but-unjudgedreported per-citation via resolve_error, never scored as unsupported.',
82
- '',
83
- 'Use when the output makes factual claims backed by [1]-style references, DOIs, or URLs and you want to separate "cited correctly" from "cited and wrong" from "cited but unresolvable". Particularly useful for research/legal/medical agents where fabricated citations are the dominant failure mode.',
84
- "",
85
- "Don't use when the agent output has no citations at all (overall_score will be null; the tool degrades gracefully but a heuristic rule is cheaper). Don't use without allow_fetch=true or IRIS_CITATION_ALLOW_FETCH=1 — the tool refuses outbound HTTP unless explicitly enabled. Don't use with an open allowlist + untrusted output on the public internet; you are effectively running a user-directed fetcher. For stricter safety set IRIS_CITATION_DOMAINS to a curated list.",
86
- '',
87
- 'Parameters. model is required; provider auto-detected from model name (override only for ambiguous IDs). allow_fetch=false by default — outbound HTTP is REFUSED unless explicitly true OR IRIS_CITATION_ALLOW_FETCH=1 env. domain_allowlist suffix-matches hostnames (e.g., "wikipedia.org" allows en.wikipedia.org); merged with IRIS_CITATION_DOMAINS env (UNION — either source permits). max_citations defaults 20, hard cap 50 (extras are skipped silently, NOT errored — check total_citations_found in the response if precise). max_cost_usd_total defaults $1.00 — the pipeline stops mid-citation when the next judge call would exceed the cap (returns partial verdicts). per_source_timeout_ms defaults 10000 (10s); per_source_max_bytes defaults 5MB (truncates at boundary, judges still run on truncated content); independently of that, the judge reads at most the first 12,000 characters of each fetched source, and the per-citation cost estimate is taken on that truncated prompt, not on the full body. trace_id optional but recommended. Defaults: max_citations=20, max_cost_usd_total=$1.00, per_source_timeout_ms=10000, per_source_max_bytes=5242880, allow_fetch=false.',
88
- '',
89
- 'Error modes. Throws when the API key env var is missing. Throws "Unknown model" on unsupported model IDs. Throws when trace_id does not match a stored trace (checked before any fetch or judge call; nothing is written). Per-citation errors are collected (resolve_error.kind = bad_scheme / ssrf / not_allowed_domain / timeout / too_large / bad_status / redirect_loop / not_text / fetch_disabled / malformed_judge_response / cost_cap_reached / unresolvable_kind) and returned in the response rather than thrown. An empty output or output with zero extractable citations returns overall_score=null + passed=true (nothing to fail).',
90
- ].join('\n'),
87
+ description: describeTool({
88
+ summary: 'Extract the citations in an output, fetch the sources (opt-in, SSRF-guarded) and ask an LLM judge on your key whether each source supports its claim.',
89
+ does: 'Three phases. Extraction, no network: [N] references, (Author, Year), bare URLs and DOIs. Fetch of URL and DOI citations only when allow_fetch is true or IRIS_CITATION_ALLOW_FETCH=1, through a scheme allowlist, private and cloud-metadata address blocking, an optional hostname allowlist (domain_allowlist, merged with IRIS_CITATION_DOMAINS), a per-source timeout and byte cap, and at most three re-checked redirects. ' +
90
+ 'Then one judge call per resolved citation on your own key, reading the first part of each source, capped in total by max_cost_usd_total. Up to max_citations are verified; extras are skipped, not errored. ' +
91
+ 'overall_score is supported / judged and null when nothing was judged. Per-citation failures (bad scheme, blocked address, timeout, too large, cost cap, fetch disabled) are reported on the citation, never scored as unsupported. One evaluation row is stored.',
92
+ whenNot: "When the output has no citations: the score is null, and evaluate_output's hallucination signals are the cheap check. " +
93
+ `Without a key (${JUDGE_KEY_VARS.anthropic} or ${JUDGE_KEY_VARS.openai}): the call returns IRIS_JUDGE_NOT_ENABLED with the enable steps. ` +
94
+ 'With fetch enabled and an open allowlist on untrusted output: you are running a user-directed fetcher set IRIS_CITATION_DOMAINS.',
95
+ returns: verifyCitationsOutputSchema,
96
+ errors: 'IRIS_JUDGE_NOT_ENABLED, IRIS_JUDGE_UNKNOWN_MODEL and IRIS_UNKNOWN_TRACE before any fetch or spend. IRIS_JUDGE_FAILED when citations resolved but the judge failed on every one an error, not a passing verdict; nothing is stored. ' +
97
+ ERROR_ENVELOPE_SENTENCE,
98
+ siblings: {
99
+ evaluate_with_llm_judge: 'general semantic scoring',
100
+ evaluate_output: 'the free deterministic path, including the hallucination signals',
101
+ log_trace: 'record the execution first',
102
+ },
103
+ }),
91
104
  inputSchema: strictInput(inputSchema),
105
+ outputSchema: verifyCitationsOutputSchema,
92
106
  annotations: {
93
107
  readOnlyHint: false, // Writes eval_result + spends money
94
108
  destructiveHint: false, // Creates data; doesn't overwrite/delete
95
109
  idempotentHint: false, // External fetches + provider non-determinism
96
110
  openWorldHint: true, // Outbound HTTP to citation URLs + LLM provider API
97
111
  },
98
- }, async (args) => {
112
+ }, guarded(async (args) => {
99
113
  const provider = args.provider ?? inferProvider(args.model);
100
- const apiKey = resolveApiKey(provider);
114
+ const apiKey = resolveApiKey(provider, 'verify_citations');
101
115
  const allowFetch = resolveAllowFetch(args.allow_fetch);
102
116
  const domainAllowlist = resolveDomainAllowlist(args.domain_allowlist);
103
117
  // Refused before any fetch or judge call spends anything (#376).
@@ -128,73 +142,85 @@ export function registerVerifyCitationsTool(server, storage) {
128
142
  eval_type: 'custom',
129
143
  output_text: args.output,
130
144
  score,
131
- passed: result.passed,
145
+ passed: result.passed === true,
132
146
  rule_results: [
133
147
  {
134
148
  ruleName: `semantic_citation_verify:${provider}/${args.model}`,
135
- passed: result.passed,
149
+ /*
150
+ * Null means nothing was judged, which is not a pass and not a
151
+ * failure — it is a check that did not run. Stored as a SKIP so
152
+ * the composer treats it as coverage rather than silently
153
+ * reading a paid-for "nothing verified" as clean.
154
+ */
155
+ passed: result.passed === null ? false : result.passed,
156
+ ...(result.passed === null
157
+ ? { skipped: true, skipReason: `no citation was judged (found ${result.totalCitationsFound}, resolved ${result.totalResolved})` }
158
+ : {}),
159
+ kind: 'judgment',
136
160
  score,
137
161
  message: result.overallScore === null
138
162
  ? `No citations judged (found ${result.totalCitationsFound}, resolved ${result.totalResolved}, judged 0)`
139
163
  : `${result.totalSupported}/${result.totalJudged} judged sources supported the output`,
140
164
  },
141
165
  ],
142
- suggestions: result.passed ? [] : [`Only ${result.totalSupported}/${result.totalJudged} judged sources actually supported the claim.`],
166
+ suggestions: result.passed === null
167
+ ? ['No citation was judged, so nothing about the sources was verified. This is not a pass.']
168
+ : result.passed
169
+ ? []
170
+ : [`${result.totalUnsupported} of ${result.totalJudged} judged sources did not support the claim.`],
143
171
  rules_evaluated: 1,
144
172
  rules_skipped: 0,
145
173
  insufficient_data: result.overallScore === null,
174
+ eval_cost_usd: result.totalCostUsd,
146
175
  });
147
- return {
148
- content: [
149
- {
150
- type: 'text',
151
- text: JSON.stringify({
152
- id: evalId,
153
- overall_score: result.overallScore,
154
- passed: result.passed,
155
- total_citations_found: result.totalCitationsFound,
156
- total_resolved: result.totalResolved,
157
- total_judged: result.totalJudged,
158
- total_supported: result.totalSupported,
159
- total_cost_usd: result.totalCostUsd,
160
- citations: result.citations.map((c) => ({
161
- citation: {
162
- raw: c.citation.raw,
163
- kind: c.citation.kind,
164
- identifier: c.citation.identifier,
165
- offset_start: c.citation.offsetStart,
166
- offset_end: c.citation.offsetEnd,
167
- },
168
- resolve_status: c.resolveStatus,
169
- resolve_error: c.resolveError,
170
- // Mapped to the documented snake_case keys. The verifier's
171
- // internal shape is camelCase (contentType, bytesFetched) and
172
- // used to be passed through verbatim, so a client parsing
173
- // `source.content_type` per the description read undefined.
174
- source: c.source
175
- ? {
176
- url: c.source.url,
177
- status: c.source.status,
178
- content_type: c.source.contentType,
179
- bytes_fetched: c.source.bytesFetched,
180
- truncated: c.source.truncated,
181
- }
182
- : undefined,
183
- judge: c.judge
184
- ? {
185
- supported: c.judge.supported,
186
- confidence: c.judge.confidence,
187
- rationale: c.judge.rationale,
188
- cost_usd: c.judge.costUsd,
189
- latency_ms: c.judge.latencyMs,
190
- input_tokens: c.judge.inputTokens,
191
- output_tokens: c.judge.outputTokens,
192
- }
193
- : undefined,
194
- })),
195
- }),
176
+ return respond(verifyCitationsOutputSchema, {
177
+ id: evalId,
178
+ ...(args.trace_id ? { trace_id: args.trace_id } : {}),
179
+ overall_score: result.overallScore,
180
+ passed: result.passed,
181
+ // Derived rather than read: the verifier reports it, but the tool
182
+ // must not break if a caller hands it an older shape.
183
+ total_unsupported: result.totalUnsupported ?? Math.max(0, result.totalJudged - result.totalSupported),
184
+ total_citations_found: result.totalCitationsFound,
185
+ total_resolved: result.totalResolved,
186
+ total_judged: result.totalJudged,
187
+ total_supported: result.totalSupported,
188
+ total_cost_usd: result.totalCostUsd,
189
+ citations: result.citations.map((c) => ({
190
+ citation: {
191
+ raw: c.citation.raw,
192
+ kind: c.citation.kind,
193
+ identifier: c.citation.identifier,
194
+ offset_start: c.citation.offsetStart,
195
+ offset_end: c.citation.offsetEnd,
196
196
  },
197
- ],
198
- };
199
- });
197
+ resolve_status: c.resolveStatus,
198
+ resolve_error: c.resolveError,
199
+ // Mapped to the documented snake_case keys. The verifier's
200
+ // internal shape is camelCase (contentType, bytesFetched) and
201
+ // used to be passed through verbatim, so a client parsing
202
+ // `source.content_type` per the description read undefined.
203
+ source: c.source
204
+ ? {
205
+ url: c.source.url,
206
+ status: c.source.status,
207
+ content_type: c.source.contentType,
208
+ bytes_fetched: c.source.bytesFetched,
209
+ truncated: c.source.truncated,
210
+ }
211
+ : undefined,
212
+ judge: c.judge
213
+ ? {
214
+ supported: c.judge.supported,
215
+ confidence: c.judge.confidence,
216
+ rationale: c.judge.rationale,
217
+ cost_usd: c.judge.costUsd,
218
+ latency_ms: c.judge.latencyMs,
219
+ input_tokens: c.judge.inputTokens,
220
+ output_tokens: c.judge.outputTokens,
221
+ }
222
+ : undefined,
223
+ })),
224
+ }, evaluationLinks(evalId, args.trace_id));
225
+ }));
200
226
  }