haliosai-cli 2.0.0__py3-none-any.whl

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.
halios_cli/cli_eval.py ADDED
@@ -0,0 +1,1075 @@
1
+ """Canonical local scenario evaluation commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import queue
9
+ import re
10
+ import secrets
11
+ import shlex
12
+ import subprocess
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ import urllib.parse
17
+ from functools import lru_cache
18
+ from importlib import resources
19
+ from typing import Any
20
+
21
+ import httpx
22
+ import typer
23
+ from jsonschema import Draft202012Validator
24
+
25
+ from .cli_support import (
26
+ ApiClient,
27
+ evaluation_suite_digest,
28
+ git_provenance,
29
+ load_project_config,
30
+ load_yaml,
31
+ resolve_credentials,
32
+ )
33
+
34
+ app = typer.Typer(help="Review, run, and report agent reliability.", no_args_is_help=True)
35
+ TRACE_LIMIT = 10_000
36
+ MAX_ADAPTER_RESPONSE_BYTES = 1_048_576
37
+ SCENARIO_FIELD_REPLACEMENTS = {
38
+ "arc_hints": "arc_messages",
39
+ "context": "agent_context",
40
+ "initial_context": "agent_context",
41
+ "intent": "goal",
42
+ "message": "initial_message",
43
+ "name": "title",
44
+ "risk": "risk_label",
45
+ "tags": "situation_tags",
46
+ "user_messages": "arc_messages",
47
+ }
48
+
49
+
50
+ @lru_cache(maxsize=1)
51
+ def _scenarios_validator() -> Draft202012Validator:
52
+ schema_resource = resources.files("halios_cli").joinpath("schemas/scenarios.schema.json")
53
+ schema = json.loads(schema_resource.read_text(encoding="utf-8"))
54
+ Draft202012Validator.check_schema(schema)
55
+ return Draft202012Validator(schema)
56
+
57
+
58
+ @lru_cache(maxsize=1)
59
+ def _eval_validator() -> Draft202012Validator:
60
+ schema_resource = resources.files("halios_cli").joinpath("schemas/eval.schema.json")
61
+ schema = json.loads(schema_resource.read_text(encoding="utf-8"))
62
+ Draft202012Validator.check_schema(schema)
63
+ return Draft202012Validator(schema)
64
+
65
+
66
+ def _eval_schema_errors(eval_plan: dict[str, Any]) -> list[str]:
67
+ errors: list[str] = []
68
+ for error in sorted(_eval_validator().iter_errors(eval_plan), key=lambda item: list(item.path)):
69
+ path = ".".join(str(part) for part in error.absolute_path)
70
+ label = f"eval.yml: {path}" if path else "eval.yml"
71
+ if error.validator == "required":
72
+ missing = str(error.message).split("'")[1]
73
+ errors.append(f"{label}: {missing} is required")
74
+ elif error.validator == "additionalProperties":
75
+ errors.append(f"{label}: {error.message}")
76
+ else:
77
+ errors.append(f"{label}: {error.message}")
78
+ checks = eval_plan.get("checks")
79
+ if isinstance(checks, list):
80
+ ids = [str(check.get("id") or "") for check in checks if isinstance(check, dict)]
81
+ explicit_ids = [item for item in ids if item]
82
+ if len(explicit_ids) != len(set(explicit_ids)):
83
+ errors.append("eval.yml: check ids must be unique")
84
+ for index, check in enumerate(checks):
85
+ if not isinstance(check, dict) or not isinstance(check.get("rules"), list):
86
+ continue
87
+ rule_ids = [
88
+ str(rule.get("id") or "")
89
+ for rule in check["rules"]
90
+ if isinstance(rule, dict) and rule.get("id")
91
+ ]
92
+ if len(rule_ids) != len(set(rule_ids)):
93
+ check_id = str(check.get("id") or index)
94
+ errors.append(f"eval.yml: check '{check_id}' rule ids must be unique")
95
+ return errors
96
+
97
+
98
+ def _format_scenario_schema_error(error: Any, payload: dict[str, Any]) -> str | None:
99
+ path = list(error.absolute_path)
100
+ label = "scenarios.yml"
101
+ field_path = path
102
+ if len(path) >= 2 and path[0] == "scenarios" and isinstance(path[1], int):
103
+ index = path[1]
104
+ raw_scenarios = payload.get("scenarios")
105
+ item = (
106
+ raw_scenarios[index]
107
+ if isinstance(raw_scenarios, list) and index < len(raw_scenarios)
108
+ else None
109
+ )
110
+ scenario_id = str(item.get("id") or "").strip() if isinstance(item, dict) else ""
111
+ label = f"Scenario '{scenario_id}'" if scenario_id else f"scenarios[{index}]"
112
+ field_path = path[2:]
113
+ field = ".".join(str(part) for part in field_path)
114
+ prefix = f"{label}: {field}" if field else label
115
+
116
+ if error.validator == "additionalProperties":
117
+ return None
118
+ if error.validator == "required":
119
+ missing = str(error.message).split("'")[1]
120
+ return f"{label}: {missing} is required"
121
+ if error.validator == "anyOf":
122
+ return f"{label}: initial_message or a non-empty arc_messages entry is required"
123
+ if error.validator == "const" and field == "version":
124
+ return "scenarios.yml: version must be 1"
125
+ if error.validator == "enum":
126
+ choices = ", ".join(str(choice) for choice in error.validator_value)
127
+ return f"{prefix} must be one of: {choices}"
128
+ if error.validator == "type":
129
+ type_name = {
130
+ "array": "a list",
131
+ "boolean": "true or false",
132
+ "integer": "an integer",
133
+ "object": "an object",
134
+ "string": "a string",
135
+ }.get(str(error.validator_value), str(error.validator_value))
136
+ return f"{prefix} must be {type_name}"
137
+ if error.validator in {"minimum", "maximum"} and field == "max_turns":
138
+ return f"{label}: max_turns must be an integer from 1 to 20"
139
+ if error.validator == "minLength":
140
+ return f"{prefix} must not be empty"
141
+ if error.validator == "minItems":
142
+ return f"{prefix} must contain at least one entry"
143
+ if error.validator == "pattern":
144
+ return f"{prefix} contains unsupported characters"
145
+ return f"{prefix}: {error.message}"
146
+
147
+
148
+ def _explicit_trace_ids(value: str | None) -> list[str]:
149
+ if not value:
150
+ return []
151
+ if value.startswith("@"):
152
+ path = pathlib.Path(value[1:])
153
+ if not path.exists():
154
+ raise typer.BadParameter(f"Trace id file not found: {path}")
155
+ values = [
156
+ line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
157
+ ]
158
+ else:
159
+ values = [item.strip() for item in value.split(",") if item.strip()]
160
+ if len(values) > TRACE_LIMIT:
161
+ raise typer.BadParameter(f"A run accepts at most {TRACE_LIMIT:,} explicit trace ids")
162
+ malformed = [
163
+ item
164
+ for item in values
165
+ if len(item) != 32 or any(c not in "0123456789abcdefABCDEF" for c in item)
166
+ ]
167
+ if malformed:
168
+ raise typer.BadParameter(f"Malformed W3C trace id: {malformed[0]}")
169
+ if len(set(item.lower() for item in values)) != len(values):
170
+ raise typer.BadParameter("Explicit trace ids must be unique")
171
+ return [item.lower() for item in values]
172
+
173
+
174
+ def _compare_reports(current: dict[str, Any], baseline: dict[str, Any], baseline_id: str) -> dict:
175
+ return {
176
+ "baseline_run_id": baseline_id,
177
+ "pass_at_k_delta": float(current.get("pass_at_k") or 0)
178
+ - float(baseline.get("pass_at_k") or 0),
179
+ "gate_changed": bool(current.get("gate_passed")) != bool(baseline.get("gate_passed")),
180
+ "telemetry_incomplete_delta": int(current.get("telemetry_incomplete_count") or 0)
181
+ - int(baseline.get("telemetry_incomplete_count") or 0),
182
+ "attempted_trial_count_delta": int(current.get("attempted_trial_count") or 0)
183
+ - int(baseline.get("attempted_trial_count") or 0),
184
+ }
185
+
186
+
187
+ def _contains_llm_judge(value: Any) -> bool:
188
+ if isinstance(value, dict):
189
+ if value.get("type") == "llm_judge" or value.get("rule_type") == "llm_judge":
190
+ return True
191
+ return any(_contains_llm_judge(item) for item in value.values())
192
+ if isinstance(value, list):
193
+ return any(_contains_llm_judge(item) for item in value)
194
+ return False
195
+
196
+
197
+ def _initial_scenario_message(scenario: dict[str, Any]) -> dict[str, str]:
198
+ initial = scenario.get("initial_message") or scenario.get("message")
199
+ if isinstance(initial, str) and initial.strip():
200
+ return {"role": "user", "content": initial.strip()}
201
+ raw_turns = scenario.get("arc_messages") or scenario.get("user_messages") or []
202
+ for raw in raw_turns:
203
+ if isinstance(raw, str):
204
+ message = {"role": "user", "content": raw.strip()}
205
+ elif isinstance(raw, dict) and raw.get("role", "user") == "user":
206
+ message = {"role": "user", "content": str(raw.get("content") or "").strip()}
207
+ else:
208
+ continue
209
+ if message["content"]:
210
+ return message
211
+ raise typer.BadParameter(f"Scenario {scenario.get('id')} has no first user message")
212
+
213
+
214
+ def _scenario_schema_errors(
215
+ scenarios_payload: dict[str, Any],
216
+ ) -> tuple[list[dict[str, Any]], list[str]]:
217
+ errors: list[str] = []
218
+ schema = _scenarios_validator().schema
219
+ root_fields = set(schema["properties"])
220
+ for field in sorted(set(scenarios_payload) - root_fields):
221
+ errors.append(f"scenarios.yml: unknown root field '{field}'")
222
+ raw_scenarios = scenarios_payload.get("scenarios")
223
+ scenario_fields = set(schema["$defs"]["scenario"]["properties"])
224
+ if isinstance(raw_scenarios, list):
225
+ for index, item in enumerate(raw_scenarios):
226
+ if not isinstance(item, dict):
227
+ continue
228
+ scenario_id = str(item.get("id") or "").strip()
229
+ label = f"Scenario '{scenario_id}'" if scenario_id else f"scenarios[{index}]"
230
+ for field in sorted(set(item) - scenario_fields):
231
+ replacement = SCENARIO_FIELD_REPLACEMENTS.get(field)
232
+ if field == "protected":
233
+ errors.append(
234
+ f"{label}: unknown field 'protected'; use risk_label: adversarial and "
235
+ "expected_guardrail_trigger: true when applicable"
236
+ )
237
+ elif replacement:
238
+ errors.append(f"{label}: unknown field '{field}'; use '{replacement}'")
239
+ else:
240
+ errors.append(f"{label}: unknown field '{field}'")
241
+
242
+ for error in sorted(
243
+ _scenarios_validator().iter_errors(scenarios_payload),
244
+ key=lambda item: (list(item.absolute_path), item.message),
245
+ ):
246
+ formatted = _format_scenario_schema_error(error, scenarios_payload)
247
+ if formatted and formatted not in errors:
248
+ errors.append(formatted)
249
+
250
+ if not isinstance(raw_scenarios, list):
251
+ return [], errors
252
+
253
+ scenarios: list[dict[str, Any]] = []
254
+ seen_ids: set[str] = set()
255
+ for index, item in enumerate(raw_scenarios):
256
+ location = f"scenarios[{index}]"
257
+ if not isinstance(item, dict):
258
+ errors.append(f"{location}: scenario must be an object")
259
+ continue
260
+ scenarios.append(item)
261
+ scenario_id = str(item.get("id") or "").strip()
262
+ label = f"Scenario '{scenario_id}'" if scenario_id else location
263
+ if not scenario_id:
264
+ errors.append(f"{location}: id is required")
265
+ elif scenario_id in seen_ids:
266
+ errors.append(f"{label}: id must be unique")
267
+ else:
268
+ seen_ids.add(scenario_id)
269
+
270
+ return scenarios, errors
271
+
272
+
273
+ def _eval_quality_gaps(eval_plan: dict[str, Any], scenarios: list[dict[str, Any]]) -> list[str]:
274
+ """Return high-confidence semantic smells that should block configuration."""
275
+ gaps: list[str] = []
276
+ for index, check in enumerate(eval_plan.get("checks") or []):
277
+ if not isinstance(check, dict):
278
+ continue
279
+ check_id = str(check.get("id") or check.get("name") or f"checks[{index}]")
280
+ check_text = " ".join(
281
+ str(check.get(field) or "") for field in ("name", "description")
282
+ ).lower()
283
+ for rule in check.get("rules") or []:
284
+ if not isinstance(rule, dict) or rule.get("type") != "llm_judge":
285
+ continue
286
+ config = rule.get("config") or {}
287
+ if not isinstance(config, dict):
288
+ continue
289
+ prompt = str(config.get("prompt_template") or config.get("rubric") or "").strip()
290
+ prompt_lower = prompt.lower()
291
+ if len(prompt) < 80:
292
+ gaps.append(
293
+ f"Check '{check_id}': LLM rubric is too vague; define one observable "
294
+ "passing outcome, one failing outcome, and insufficient evidence"
295
+ )
296
+ if not any(
297
+ phrase in prompt_lower
298
+ for phrase in (
299
+ "cannot determine",
300
+ "insufficient evidence",
301
+ "not applicable",
302
+ 'score": null',
303
+ "unknown",
304
+ )
305
+ ):
306
+ gaps.append(
307
+ f"Check '{check_id}': LLM rubric must define an insufficient-evidence "
308
+ "or not-applicable outcome"
309
+ )
310
+ numbered_criteria = re.findall(r"(?m)^\s*\d+[.)]\s+", prompt)
311
+ if prompt.count("?") > 1 or len(numbered_criteria) > 1:
312
+ gaps.append(
313
+ f"Check '{check_id}': LLM rubric appears to grade multiple criteria; "
314
+ "split it into one check per criterion"
315
+ )
316
+ competitor_text = f"{check_text} {prompt_lower}"
317
+ requires_recommendation = re.search(
318
+ r"\b(?:must|should|required to)\s+recommend\b", prompt_lower
319
+ )
320
+ preserves_boundary = re.search(
321
+ r"\b(?:must not|should not|do not|does not|never)\s+recommend\b|"
322
+ r"\b(?:refus|redirect|unsupported|supported catalog|supported catalogue)\w*",
323
+ prompt_lower,
324
+ )
325
+ if (
326
+ "competitor" in competitor_text
327
+ and requires_recommendation
328
+ and not preserves_boundary
329
+ ):
330
+ gaps.append(
331
+ f"Check '{check_id}': competitor-policy rubric appears inverted because it "
332
+ "requires a recommendation; grade refusal/redirection to supported options"
333
+ )
334
+
335
+ for scenario in scenarios:
336
+ scenario_id = str(scenario.get("id") or "unnamed")
337
+ constraints = " ".join(str(item) for item in scenario.get("constraints") or [])
338
+ if re.search(
339
+ r"\b(?:assistant|agent)\s+must\s+(?:say|reply|respond with|recommend)\b",
340
+ constraints,
341
+ re.IGNORECASE,
342
+ ):
343
+ gaps.append(
344
+ f"Scenario '{scenario_id}': constraints prescribe an assistant answer; "
345
+ "describe the user pressure and let checks grade the outcome"
346
+ )
347
+ return list(dict.fromkeys(gaps))
348
+
349
+
350
+ def _review_suite(eval_plan: dict[str, Any], scenarios_payload: dict[str, Any]) -> dict[str, Any]:
351
+ scenarios, schema_errors = _scenario_schema_errors(scenarios_payload)
352
+ eval_schema_errors = _eval_schema_errors(eval_plan)
353
+ checks = eval_plan.get("checks") or []
354
+ schema_errors = [*eval_schema_errors, *schema_errors]
355
+ gaps = list(schema_errors)
356
+ if not eval_plan.get("goals"):
357
+ gaps.append("Define at least one agent goal")
358
+ if not eval_plan.get("risks"):
359
+ gaps.append("Define concrete failure risks")
360
+ if not scenarios:
361
+ gaps.append("Add at least one scenario")
362
+ if not checks:
363
+ gaps.append("Add deterministic or evaluator checks")
364
+ if scenarios and not any(item.get("risk_label") == "adversarial" for item in scenarios):
365
+ gaps.append("Add at least one scenario with risk_label: adversarial")
366
+ quality_gaps = _eval_quality_gaps(eval_plan, scenarios)
367
+ gaps.extend(quality_gaps)
368
+ return {
369
+ "status": "ready" if not gaps else "needs-work",
370
+ "scenario_count": len(scenarios),
371
+ "check_count": len(checks),
372
+ "schema_errors": schema_errors,
373
+ "quality_gaps": quality_gaps,
374
+ "coverage_gaps": gaps,
375
+ }
376
+
377
+
378
+ def _invoke_adapter(
379
+ *,
380
+ command: str,
381
+ root: pathlib.Path,
382
+ trial_id: str,
383
+ traceparent: str,
384
+ scenario: dict[str, Any],
385
+ environment: dict[str, str],
386
+ api: ApiClient,
387
+ run_id: str,
388
+ turn_timeout_seconds: float = 120,
389
+ ) -> tuple[list[dict[str, str]], str, str | None, dict[str, Any]]:
390
+ if not command:
391
+ raise typer.BadParameter("No agent.command in .halios/config.toml")
392
+ if turn_timeout_seconds <= 0:
393
+ raise typer.BadParameter("Adapter turn timeout must be greater than zero")
394
+ conversation: list[dict[str, str]] = []
395
+ stderr_file = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
396
+ process = subprocess.Popen(
397
+ shlex.split(command),
398
+ cwd=root,
399
+ stdin=subprocess.PIPE,
400
+ stdout=subprocess.PIPE,
401
+ stderr=stderr_file,
402
+ text=True,
403
+ bufsize=1,
404
+ env=environment,
405
+ )
406
+ stdout_lines: queue.Queue[tuple[str, str]] = queue.Queue()
407
+
408
+ def read_stdout() -> None:
409
+ assert process.stdout is not None
410
+ try:
411
+ for line in process.stdout:
412
+ stdout_lines.put(("line", line))
413
+ except Exception as exc: # pragma: no cover - OS stream failures are platform-specific.
414
+ stdout_lines.put(("error", str(exc)))
415
+ finally:
416
+ stdout_lines.put(("eof", ""))
417
+
418
+ threading.Thread(target=read_stdout, daemon=True).start()
419
+
420
+ def stderr_excerpt() -> str:
421
+ stderr_file.flush()
422
+ stderr_file.seek(0)
423
+ return stderr_file.read()[-4096:].strip()
424
+
425
+ try:
426
+ assert process.stdin is not None and process.stdout is not None
427
+ user_message = _initial_scenario_message(scenario)
428
+ max_turns = int(scenario.get("max_turns") or 6)
429
+ outcome = "completed"
430
+ stop_reason: str | None = None
431
+ for _turn_index in range(max_turns):
432
+ conversation.append(user_message)
433
+ request = {
434
+ "version": "1",
435
+ "trial_id": trial_id,
436
+ "message": user_message,
437
+ "messages": conversation,
438
+ "context": scenario.get("agent_context") or {},
439
+ "traceparent": traceparent,
440
+ }
441
+ process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n")
442
+ process.stdin.flush()
443
+ try:
444
+ event, raw_response = stdout_lines.get(timeout=turn_timeout_seconds)
445
+ except queue.Empty as exc:
446
+ raise RuntimeError(
447
+ f"Adapter did not respond within {turn_timeout_seconds:g}s"
448
+ ) from exc
449
+ if event == "error":
450
+ raise RuntimeError(f"Could not read adapter response: {raw_response}")
451
+ if event == "eof":
452
+ detail = stderr_excerpt()
453
+ suffix = f": {detail}" if detail else ""
454
+ raise RuntimeError(f"Adapter exited without a response{suffix}")
455
+ if len(raw_response.encode("utf-8")) > MAX_ADAPTER_RESPONSE_BYTES:
456
+ raise RuntimeError("jsonl-v1 response exceeds the 1 MiB limit")
457
+ try:
458
+ response = json.loads(raw_response)
459
+ except json.JSONDecodeError as exc:
460
+ raise RuntimeError("Adapter stdout must contain only jsonl-v1 responses") from exc
461
+ message = response.get("message") if isinstance(response, dict) else None
462
+ if not isinstance(message, dict) or message.get("role") != "assistant":
463
+ raise RuntimeError("jsonl-v1 response must contain one assistant message")
464
+ content = message.get("content")
465
+ if not isinstance(content, str):
466
+ raise RuntimeError("jsonl-v1 assistant message content must be a string")
467
+ conversation.append({"role": "assistant", "content": content})
468
+ next_turn = api.request(
469
+ "POST",
470
+ f"/api/v1/runs/evaluations/{run_id}/trials/{trial_id}/next-turn",
471
+ json={"messages": conversation},
472
+ )
473
+ outcome = str(next_turn.get("outcome") or outcome)
474
+ if next_turn.get("stop"):
475
+ stop_reason = str(next_turn.get("stop_reason") or "") or None
476
+ break
477
+ next_message = next_turn.get("message")
478
+ if not isinstance(next_message, dict) or next_message.get("role") != "user":
479
+ raise RuntimeError("Simulated-user response must contain one user message")
480
+ user_message = {
481
+ "role": "user",
482
+ "content": str(next_message.get("content") or ""),
483
+ }
484
+ return conversation, outcome, stop_reason, {}
485
+ except Exception as exc:
486
+ return (
487
+ conversation,
488
+ "errored",
489
+ "adapter_error",
490
+ {"code": "adapter_error", "message": str(exc)},
491
+ )
492
+ finally:
493
+ if process.stdin:
494
+ try:
495
+ process.stdin.close()
496
+ except BrokenPipeError:
497
+ pass
498
+ try:
499
+ process.wait(timeout=5)
500
+ except subprocess.TimeoutExpired:
501
+ process.terminate()
502
+ try:
503
+ process.wait(timeout=5)
504
+ except subprocess.TimeoutExpired:
505
+ process.kill()
506
+ process.wait(timeout=5)
507
+ if process.stdout:
508
+ process.stdout.close()
509
+ stderr_file.close()
510
+
511
+
512
+ def _otlp_root_payload(
513
+ *,
514
+ trace_id: str,
515
+ span_id: str,
516
+ started_ns: int,
517
+ ended_ns: int,
518
+ conversation: list[dict[str, str]],
519
+ app_name: str,
520
+ service_version: str | None,
521
+ evaluation_context: str = "ad_hoc",
522
+ outcome: str = "completed",
523
+ error: dict[str, Any] | None = None,
524
+ ) -> dict[str, Any]:
525
+ def otel_messages(messages: list[dict[str, str]]) -> list[dict[str, Any]]:
526
+ return [
527
+ {
528
+ "role": message.get("role") or "user",
529
+ "parts": [{"type": "text", "content": message.get("content") or ""}],
530
+ }
531
+ for message in messages
532
+ ]
533
+
534
+ resource_attributes = [
535
+ {"key": "service.name", "value": {"stringValue": app_name}},
536
+ {
537
+ "key": "deployment.environment.name",
538
+ "value": {"stringValue": evaluation_context},
539
+ },
540
+ ]
541
+ if service_version:
542
+ resource_attributes.append(
543
+ {"key": "service.version", "value": {"stringValue": service_version}}
544
+ )
545
+ return {
546
+ "resourceSpans": [
547
+ {
548
+ "resource": {"attributes": resource_attributes},
549
+ "scopeSpans": [
550
+ {
551
+ "scope": {"name": "halios.cli", "version": "1"},
552
+ "spans": [
553
+ {
554
+ "traceId": trace_id,
555
+ "spanId": span_id,
556
+ "name": "execute_agent",
557
+ "kind": 1,
558
+ "startTimeUnixNano": str(started_ns),
559
+ "endTimeUnixNano": str(ended_ns),
560
+ "attributes": [
561
+ {
562
+ "key": "gen_ai.operation.name",
563
+ "value": {"stringValue": "invoke_agent"},
564
+ },
565
+ {
566
+ "key": "gen_ai.input.messages",
567
+ "value": {
568
+ "stringValue": json.dumps(
569
+ otel_messages(conversation[:-1])
570
+ )
571
+ },
572
+ },
573
+ {
574
+ "key": "gen_ai.output.messages",
575
+ "value": {
576
+ "stringValue": json.dumps(
577
+ otel_messages(conversation[-1:])
578
+ )
579
+ },
580
+ },
581
+ ],
582
+ "status": {
583
+ "code": 2 if outcome == "errored" else 1,
584
+ **(
585
+ {"message": str((error or {}).get("message") or "")}
586
+ if outcome == "errored"
587
+ else {}
588
+ ),
589
+ },
590
+ }
591
+ ],
592
+ }
593
+ ],
594
+ }
595
+ ]
596
+ }
597
+
598
+
599
+ def _evaluation_telemetry_identity(*, ci: bool) -> tuple[str, str, str]:
600
+ """Return check source, trace origin, and legacy context for a CLI eval run."""
601
+ if ci:
602
+ return "ci", "replay", "ci"
603
+ return "sdk", "synthetic", "ad_hoc"
604
+
605
+
606
+ def _otlp_endpoint(base_url: str, *, trace_origin: str, evaluation_context: str) -> str:
607
+ query = urllib.parse.urlencode(
608
+ {"source": trace_origin, "evaluation_context": evaluation_context}
609
+ )
610
+ return f"{base_url}/v1/traces?{query}"
611
+
612
+
613
+ def _resource_attributes_with_environment(existing: str | None, environment: str) -> str:
614
+ attributes = [
615
+ item.strip()
616
+ for item in str(existing or "").split(",")
617
+ if item.strip() and not item.strip().startswith("deployment.environment.name=")
618
+ ]
619
+ attributes.append(f"deployment.environment.name={environment}")
620
+ return ",".join(attributes)
621
+
622
+
623
+ def _verify_simulation_telemetry(
624
+ api: ApiClient,
625
+ *,
626
+ report: dict[str, Any],
627
+ agent_id: str,
628
+ expected_roots: dict[str, str],
629
+ ) -> dict[str, Any]:
630
+ expected_count = len(expected_roots)
631
+ if int(report.get("attempted_trial_count") or 0) != expected_count:
632
+ raise typer.BadParameter(
633
+ "Telemetry verification failed: attempted trial count does not match simulation"
634
+ )
635
+ if int(report.get("telemetry_incomplete_count") or 0):
636
+ raise typer.BadParameter(
637
+ "Telemetry verification failed: one or more simulation traces were not received "
638
+ "before the telemetry deadline"
639
+ )
640
+ if int(report.get("completed_trial_count") or 0) != expected_count:
641
+ raise typer.BadParameter(
642
+ "Telemetry verification failed: not every simulation trace completed evaluation"
643
+ )
644
+ if int(report.get("evaluated_trial_count") or 0) != expected_count:
645
+ raise typer.BadParameter(
646
+ "Evaluation verification failed: not every simulation trace produced check executions"
647
+ )
648
+
649
+ expected_check_ids = {
650
+ str(check.get("id"))
651
+ for check in ((report.get("snapshot") or {}).get("checks") or [])
652
+ if isinstance(check, dict) and check.get("id")
653
+ }
654
+
655
+ for trace_id, root_span_id in expected_roots.items():
656
+ detail = api.request("GET", f"/api/v1/traces/{trace_id}")
657
+ if str(detail.get("trace_id") or "") != trace_id:
658
+ raise typer.BadParameter(
659
+ f"Telemetry verification failed for {trace_id}: trace mismatch"
660
+ )
661
+ if str(detail.get("agent_id") or "") != agent_id:
662
+ raise typer.BadParameter(
663
+ f"Telemetry verification failed for {trace_id}: agent scope mismatch"
664
+ )
665
+ membership = detail.get("evaluation_membership") or {}
666
+ if membership.get("run_id") != report.get("run_id"):
667
+ raise typer.BadParameter(
668
+ f"Telemetry verification failed for {trace_id}: evaluation membership is missing"
669
+ )
670
+ spans = detail.get("spans") or []
671
+ if not isinstance(spans, list):
672
+ raise typer.BadParameter(
673
+ f"Telemetry verification failed for {trace_id}: spans must be a list"
674
+ )
675
+ span_by_id = {str(span.get("span_id")): span for span in spans if isinstance(span, dict)}
676
+ root = span_by_id.get(root_span_id)
677
+ if not root:
678
+ raise typer.BadParameter(
679
+ f"Telemetry verification failed for {trace_id}: expected root span was not stored"
680
+ )
681
+ if root.get("parent_span_id"):
682
+ raise typer.BadParameter(
683
+ f"Telemetry verification failed for {trace_id}: root span has a parent"
684
+ )
685
+ if not root.get("ended_at"):
686
+ raise typer.BadParameter(
687
+ f"Telemetry verification failed for {trace_id}: root span has no end time"
688
+ )
689
+ for field in ("input", "output"):
690
+ content = root.get(field)
691
+ if not isinstance(content, dict) or not isinstance(content.get("messages"), list):
692
+ raise typer.BadParameter(
693
+ f"Telemetry verification failed for {trace_id}: root span {field} messages "
694
+ "are missing"
695
+ )
696
+ for span_id, span in span_by_id.items():
697
+ if len(span_id) != 16 or any(
698
+ character not in "0123456789abcdef" for character in span_id
699
+ ):
700
+ raise typer.BadParameter(
701
+ f"Telemetry verification failed for {trace_id}: malformed W3C span id"
702
+ )
703
+ if str(span.get("trace_id") or "") != trace_id:
704
+ raise typer.BadParameter(
705
+ f"Telemetry verification failed for {trace_id}: a span belongs to another trace"
706
+ )
707
+ parent_span_id = str(span.get("parent_span_id") or "")
708
+ if parent_span_id and parent_span_id not in span_by_id:
709
+ raise typer.BadParameter(
710
+ f"Telemetry verification failed for {trace_id}: span {span_id} has an "
711
+ "unknown parent"
712
+ )
713
+ child_spans = [span for span_id, span in span_by_id.items() if span_id != root_span_id]
714
+ incomplete_tool_spans = []
715
+ for span in child_spans:
716
+ attributes = span.get("attributes") if isinstance(span.get("attributes"), dict) else {}
717
+ span_name = str(span.get("name") or "").lower()
718
+ is_tool_span = (
719
+ str(span.get("kind") or "").lower() == "tool"
720
+ or span_name.startswith(("tool.", "execute_tool"))
721
+ or bool(attributes.get("tool.name") or attributes.get("gen_ai.tool.name"))
722
+ or attributes.get("gen_ai.operation.name") == "execute_tool"
723
+ )
724
+ if is_tool_span and (span.get("input") is None or span.get("output") is None):
725
+ incomplete_tool_spans.append(str(span.get("span_id") or "unknown"))
726
+ if incomplete_tool_spans:
727
+ raise typer.BadParameter(
728
+ f"Telemetry verification failed for {trace_id}: "
729
+ f"{len(incomplete_tool_spans)} tool spans are missing structured "
730
+ "arguments or results"
731
+ )
732
+ has_child_content = any(span.get("input") or span.get("output") for span in child_spans)
733
+ if child_spans and not has_child_content:
734
+ raise typer.BadParameter(
735
+ f"Telemetry verification failed for {trace_id}: instrumented child spans have "
736
+ "no captured input or output"
737
+ )
738
+
739
+ execution_page = api.request(
740
+ "GET", f"/api/v1/traces/{trace_id}/checks", params={"mode": "evaluator", "limit": 100}
741
+ )
742
+ executions = execution_page.get("data") or []
743
+ observed_check_ids = {
744
+ str(execution.get("check_id"))
745
+ for execution in executions
746
+ if isinstance(execution, dict) and execution.get("check_id")
747
+ }
748
+ missing_check_ids = expected_check_ids - observed_check_ids
749
+ if missing_check_ids:
750
+ raise typer.BadParameter(
751
+ f"Evaluation verification failed for {trace_id}: "
752
+ f"{len(missing_check_ids)} configured checks produced no execution"
753
+ )
754
+ execution_errors = [
755
+ execution
756
+ for execution in executions
757
+ if isinstance(execution, dict)
758
+ and (execution.get("error") or execution.get("status") == "error")
759
+ ]
760
+ if execution_errors:
761
+ raise typer.BadParameter(
762
+ f"Evaluation verification failed for {trace_id}: "
763
+ f"{len(execution_errors)} check executions contain errors"
764
+ )
765
+ return {"verified": True, "trace_count": expected_count}
766
+
767
+
768
+ def _raise_for_failed_run(report: dict[str, Any], run_id: str) -> None:
769
+ check_execution_error_count = int(report.get("check_execution_error_count") or 0)
770
+ failed_trials = [
771
+ trial
772
+ for trial in (report.get("trials") or [])
773
+ if isinstance(trial, dict)
774
+ and (
775
+ trial.get("error")
776
+ or trial.get("state") == "evaluation_failed"
777
+ or trial.get("outcome") in {"error", "errored", "timed_out", "blocked"}
778
+ )
779
+ ]
780
+ if report.get("status") != "failed" and not failed_trials and check_execution_error_count == 0:
781
+ return
782
+
783
+ details: list[str] = []
784
+ if check_execution_error_count:
785
+ details.append(f"{check_execution_error_count} check execution(s) errored")
786
+ for trial in failed_trials[:3]:
787
+ error = trial.get("error") if isinstance(trial.get("error"), dict) else {}
788
+ message = str(error.get("message") or trial.get("outcome") or trial.get("state"))
789
+ details.append(f"{trial.get('scenario_id') or trial.get('id')}: {message}")
790
+ suffix = f" ({'; '.join(details)})" if details else ""
791
+ raise typer.BadParameter(
792
+ f"Evaluation run {run_id} failed{suffix}. "
793
+ f"Inspect `halios eval report {run_id} --failures --json`."
794
+ )
795
+
796
+
797
+ @app.command("review")
798
+ def review(json_output: bool = typer.Option(False, "--json")) -> None:
799
+ """Validate the local suite and report design/coverage gaps without mutating it."""
800
+ root, _config = load_project_config()
801
+ eval_plan = load_yaml(root / ".halios" / "eval.yml")
802
+ scenarios_payload = load_yaml(root / ".halios" / "scenarios.yml")
803
+ result = _review_suite(eval_plan, scenarios_payload)
804
+ if json_output:
805
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
806
+ else:
807
+ typer.echo(
808
+ f"Eval review: {result['status']} "
809
+ f"({result['scenario_count']} scenarios, {result['check_count']} checks)"
810
+ )
811
+ for gap in result["coverage_gaps"]:
812
+ typer.echo(f"- {gap}")
813
+ if result["status"] != "ready":
814
+ raise typer.Exit(code=1)
815
+
816
+
817
+ @app.command("run")
818
+ def run(
819
+ repetitions: int = typer.Option(3, "--repetitions", "-k", min=1, max=20),
820
+ fail_below: float | None = typer.Option(None, "--fail-below", min=0.0, max=1.0),
821
+ scenario_id: str | None = typer.Option(None, "--scenario"),
822
+ from_traces: str | None = typer.Option(None, "--from-traces"),
823
+ tag: list[str] | None = typer.Option(None, "--tag"),
824
+ run_name: str = typer.Option("eval", "--run-name"),
825
+ publish: bool = typer.Option(
826
+ False, "--publish", help="Publish from trusted default-branch CI."
827
+ ),
828
+ default_branch: str = typer.Option("main", "--default-branch"),
829
+ timeout: int = typer.Option(600, "--timeout", min=10),
830
+ adapter_timeout: int = typer.Option(
831
+ 120,
832
+ "--adapter-timeout",
833
+ min=1,
834
+ max=900,
835
+ help="Maximum seconds allowed for each agent response.",
836
+ ),
837
+ json_output: bool = typer.Option(False, "--json"),
838
+ ) -> None:
839
+ """Run the current verified server-owned evaluation suite."""
840
+ root, config = load_project_config()
841
+ agent_config = config.get("agent") or {}
842
+ agent_id = str(agent_config.get("id") or "")
843
+ profile = str(config.get("profile") or "default")
844
+ credentials = resolve_credentials(profile, agent_id)
845
+ trace_ids = _explicit_trace_ids(from_traces)
846
+ if not trace_ids:
847
+ if agent_config.get("protocol") != "jsonl-v1":
848
+ raise typer.BadParameter("agent.protocol must be jsonl-v1")
849
+ if not str(agent_config.get("command") or "").strip():
850
+ raise typer.BadParameter("No agent.command in .halios/config.toml")
851
+ if not credentials.otlp_token:
852
+ raise typer.BadParameter("Missing OTLP token; rerun `halios project init --agent ...`")
853
+ provenance = git_provenance(root)
854
+ source, trace_origin, evaluation_context = _evaluation_telemetry_identity(
855
+ ci=bool(os.getenv("CI"))
856
+ )
857
+ otlp_endpoint = _otlp_endpoint(
858
+ credentials.base_url,
859
+ trace_origin=trace_origin,
860
+ evaluation_context=evaluation_context,
861
+ )
862
+ with ApiClient(credentials) as api:
863
+ suite = api.request("GET", f"/api/v1/agents/{agent_id}/evaluation-suite")
864
+ suite_revision = int(suite.get("revision") or 0)
865
+ if suite_revision < 1:
866
+ raise typer.BadParameter(
867
+ "Evaluation suite is not configured; run `halios project configure`"
868
+ )
869
+ local_revision = int((config.get("suite") or {}).get("revision") or 0)
870
+ if local_revision != suite_revision:
871
+ raise typer.BadParameter(
872
+ "Local evaluation suite checkout is stale; run `halios project refresh`"
873
+ )
874
+ local_eval = load_yaml(root / ".halios" / "eval.yml")
875
+ local_scenarios = load_yaml(root / ".halios" / "scenarios.yml")
876
+ if evaluation_suite_digest(local_eval, local_scenarios) != suite.get("digest"):
877
+ raise typer.BadParameter(
878
+ "Local evaluation suite has unconfigured edits; run "
879
+ "`halios project configure` or `halios project refresh`"
880
+ )
881
+ if (suite.get("verification") or {}).get("verified") is not True:
882
+ raise typer.BadParameter("Persistent evaluation suite verification failed")
883
+ eval_plan = suite.get("eval") or {}
884
+ scenarios = [] if trace_ids else (suite.get("scenarios") or {}).get("scenarios") or []
885
+ if scenario_id:
886
+ scenarios = [item for item in scenarios if str(item.get("id")) == scenario_id]
887
+ if not scenarios:
888
+ raise typer.BadParameter(f"Scenario not found: {scenario_id}")
889
+ requires_ai = (
890
+ scenarios and any(int(item.get("max_turns") or 6) > 1 for item in scenarios)
891
+ ) or _contains_llm_judge(eval_plan)
892
+ if requires_ai:
893
+ capability = api.request("GET", "/api/v1/ai/capability")
894
+ if not capability.get("evaluation_available"):
895
+ raise typer.BadParameter(
896
+ capability.get("remediation") or "Evaluation BYOK provider is unavailable"
897
+ )
898
+ created = api.request(
899
+ "POST",
900
+ "/api/v1/runs/evaluations",
901
+ json={
902
+ "agent_id": agent_id,
903
+ "run_name": run_name,
904
+ "source": source,
905
+ "suite_revision": suite_revision,
906
+ "scenario_ids": [scenario_id] if scenario_id else [],
907
+ "repetitions": 1 if trace_ids else repetitions,
908
+ "trace_ids": trace_ids,
909
+ "labels": tag or [],
910
+ "gate": {"fail_below": fail_below} if fail_below is not None else {},
911
+ "provenance": provenance,
912
+ },
913
+ )
914
+ run_id = str(created["run_id"])
915
+ typer.echo(f"Evaluation run {run_id} created; waiting for completion.", err=True)
916
+ if publish:
917
+ attestation = os.getenv("HALIOS_CI_PUBLISH_TOKEN")
918
+ if not attestation:
919
+ raise typer.BadParameter("HALIOS_CI_PUBLISH_TOKEN is required for --publish")
920
+ api.request(
921
+ "POST",
922
+ f"/api/v1/runs/evaluations/{run_id}/publish",
923
+ json={"default_branch": default_branch},
924
+ headers={"X-Halios-CI-Attestation": attestation},
925
+ )
926
+
927
+ expected_roots: dict[str, str] = {}
928
+ if not trace_ids:
929
+ scenarios_by_id = {str(item["id"]): item for item in scenarios}
930
+ for trial in created["trials"]:
931
+ trace_id = secrets.token_hex(16)
932
+ root_span_id = secrets.token_hex(8)
933
+ expected_roots[trace_id] = root_span_id
934
+ traceparent = f"00-{trace_id}-{root_span_id}-01"
935
+ api.request(
936
+ "POST",
937
+ f"/api/v1/runs/evaluations/{run_id}/trials/{trial['id']}/start",
938
+ json={"trace_id": trace_id, "root_span_id": root_span_id},
939
+ )
940
+ started_ns = time.time_ns()
941
+ adapter_environment = {
942
+ **os.environ,
943
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": otlp_endpoint,
944
+ "OTEL_EXPORTER_OTLP_HEADERS": (
945
+ f"Authorization=Bearer%20{credentials.otlp_token}"
946
+ ),
947
+ "OTEL_SERVICE_NAME": str(
948
+ config.get("app_name") or agent_config.get("slug") or "agent"
949
+ ),
950
+ "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true",
951
+ "DEPLOYMENT_ENV": evaluation_context,
952
+ "OTEL_RESOURCE_ATTRIBUTES": _resource_attributes_with_environment(
953
+ os.getenv("OTEL_RESOURCE_ATTRIBUTES"), evaluation_context
954
+ ),
955
+ }
956
+ conversation, outcome, stop_reason, error = _invoke_adapter(
957
+ command=str(agent_config.get("command") or ""),
958
+ root=root,
959
+ trial_id=str(trial["id"]),
960
+ traceparent=traceparent,
961
+ scenario=scenarios_by_id[str(trial["scenario_id"])],
962
+ environment=adapter_environment,
963
+ api=api,
964
+ run_id=run_id,
965
+ turn_timeout_seconds=adapter_timeout,
966
+ )
967
+ ended_ns = time.time_ns()
968
+ api.request(
969
+ "POST",
970
+ f"/api/v1/runs/evaluations/{run_id}/trials/{trial['id']}/complete",
971
+ json={
972
+ "trace_id": trace_id,
973
+ "root_span_id": root_span_id,
974
+ "outcome": outcome,
975
+ "stop_reason": stop_reason,
976
+ "error": error,
977
+ },
978
+ )
979
+ otlp_response = httpx.post(
980
+ otlp_endpoint,
981
+ headers={"Authorization": f"Bearer {credentials.otlp_token}"},
982
+ json=_otlp_root_payload(
983
+ trace_id=trace_id,
984
+ span_id=root_span_id,
985
+ started_ns=started_ns,
986
+ ended_ns=ended_ns,
987
+ conversation=conversation,
988
+ app_name=str(config.get("app_name") or "agent"),
989
+ service_version=provenance.get("commit_sha"),
990
+ evaluation_context=evaluation_context,
991
+ outcome=outcome,
992
+ error=error,
993
+ ),
994
+ timeout=30,
995
+ )
996
+ if otlp_response.is_error:
997
+ raise typer.BadParameter(
998
+ f"OTLP root export failed: {otlp_response.status_code}"
999
+ )
1000
+
1001
+ deadline = time.monotonic() + timeout
1002
+ report: dict[str, Any] = {}
1003
+ last_status: str | None = None
1004
+ while time.monotonic() < deadline:
1005
+ report = api.request("GET", f"/api/v1/runs/evaluations/{run_id}")
1006
+ current_status = str(report.get("status") or "unknown")
1007
+ if current_status != last_status:
1008
+ typer.echo(f"Evaluation run {run_id}: {current_status}.", err=True)
1009
+ last_status = current_status
1010
+ if current_status in {"completed", "failed"}:
1011
+ break
1012
+ time.sleep(2)
1013
+ else:
1014
+ raise typer.BadParameter(
1015
+ f"Evaluation run {run_id} did not finish within {timeout}s. "
1016
+ f"Inspect `halios eval report {run_id} --failures --json`."
1017
+ )
1018
+ _raise_for_failed_run(report, run_id)
1019
+ if expected_roots:
1020
+ report["telemetry_verification"] = _verify_simulation_telemetry(
1021
+ api,
1022
+ report=report,
1023
+ agent_id=agent_id,
1024
+ expected_roots=expected_roots,
1025
+ )
1026
+
1027
+ if json_output:
1028
+ typer.echo(json.dumps(report, indent=2, sort_keys=True))
1029
+ else:
1030
+ pass_at_k = float(report.get("pass_at_k") or 0)
1031
+ k = 1 if trace_ids else repetitions
1032
+ gate = "pass" if report.get("gate_passed") else "fail"
1033
+ telemetry = report.get("telemetry_verification") or {}
1034
+ if telemetry.get("verified"):
1035
+ typer.echo(f"Telemetry: verified ({telemetry['trace_count']} traces)")
1036
+ typer.echo(f"Run {run_id}: pass@{k}={pass_at_k:.1%} gate={gate}")
1037
+ if not report.get("gate_passed"):
1038
+ raise typer.Exit(2)
1039
+
1040
+
1041
+ @app.command("report")
1042
+ def report(
1043
+ run_id: str,
1044
+ failures: bool = typer.Option(False, "--failures"),
1045
+ compare: str | None = typer.Option(None, "--compare", help="Baseline immutable run id."),
1046
+ json_output: bool = typer.Option(False, "--json"),
1047
+ ) -> None:
1048
+ """Return immutable run evidence in human- or machine-readable form."""
1049
+ _root, config = load_project_config()
1050
+ agent_id = str((config.get("agent") or {}).get("id") or "")
1051
+ credentials = resolve_credentials(str(config.get("profile") or "default"), agent_id)
1052
+ with ApiClient(credentials) as api:
1053
+ result = api.request("GET", f"/api/v1/runs/evaluations/{run_id}")
1054
+ baseline = api.request("GET", f"/api/v1/runs/evaluations/{compare}") if compare else None
1055
+ if baseline:
1056
+ result["comparison"] = _compare_reports(result, baseline, str(compare))
1057
+ if failures:
1058
+ result = {
1059
+ **result,
1060
+ "trials": [item for item in result.get("trials", []) if not item.get("passed")],
1061
+ }
1062
+ if json_output:
1063
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
1064
+ else:
1065
+ comparison = result.get("comparison") or {}
1066
+ delta = (
1067
+ f" delta={float(comparison['pass_at_k_delta']):+.1%} vs {compare}" if comparison else ""
1068
+ )
1069
+ typer.echo(
1070
+ f"{run_id}: pass@k={float(result.get('pass_at_k') or 0):.1%} "
1071
+ f"gate={'pass' if result.get('gate_passed') else 'fail'} "
1072
+ f"check_errors={int(result.get('check_execution_error_count') or 0)} "
1073
+ f"trial_failures={int(result.get('evaluation_failed_count') or 0)} "
1074
+ f"revision={result.get('report_revision')}{delta}"
1075
+ )