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/__init__.py +5 -0
- halios_cli/_version.py +1 -0
- halios_cli/cli.py +47 -0
- halios_cli/cli_auth.py +162 -0
- halios_cli/cli_eval.py +1075 -0
- halios_cli/cli_optimize.py +308 -0
- halios_cli/cli_project.py +404 -0
- halios_cli/cli_scenario.py +96 -0
- halios_cli/cli_support.py +373 -0
- halios_cli/cli_trace.py +175 -0
- halios_cli/py.typed +1 -0
- halios_cli/schemas/__init__.py +1 -0
- halios_cli/schemas/eval.schema.json +105 -0
- halios_cli/schemas/scenarios.schema.json +96 -0
- haliosai_cli-2.0.0.dist-info/METADATA +101 -0
- haliosai_cli-2.0.0.dist-info/RECORD +20 -0
- haliosai_cli-2.0.0.dist-info/WHEEL +5 -0
- haliosai_cli-2.0.0.dist-info/entry_points.txt +2 -0
- haliosai_cli-2.0.0.dist-info/licenses/LICENSE +200 -0
- haliosai_cli-2.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|