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
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Shared CLI configuration, credentials, HTTP, and project helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://app.halios.ai"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(typer.BadParameter):
|
|
24
|
+
def __init__(self, status_code: int, detail: Any):
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.detail = detail
|
|
27
|
+
super().__init__(f"Halios API {status_code}: {detail}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_url(value: str) -> str:
|
|
31
|
+
return (value if "://" in value else f"http://{value}").rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def credentials_path() -> pathlib.Path:
|
|
35
|
+
if os.getenv("HALIOS_CONFIG_HOME"):
|
|
36
|
+
root = pathlib.Path(os.environ["HALIOS_CONFIG_HOME"])
|
|
37
|
+
elif os.getenv("XDG_CONFIG_HOME"):
|
|
38
|
+
root = pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "halios"
|
|
39
|
+
elif platform.system() == "Darwin":
|
|
40
|
+
root = pathlib.Path.home() / "Library" / "Application Support" / "halios"
|
|
41
|
+
elif platform.system() == "Windows" and os.getenv("APPDATA"):
|
|
42
|
+
root = pathlib.Path(os.environ["APPDATA"]) / "Halios"
|
|
43
|
+
else:
|
|
44
|
+
root = pathlib.Path.home() / ".config" / "halios"
|
|
45
|
+
return root / "credentials.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_credentials() -> dict[str, Any]:
|
|
49
|
+
path = credentials_path()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {"version": 1, "profiles": {}}
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
55
|
+
raise typer.BadParameter(f"Invalid CLI credential file: {exc}") from exc
|
|
56
|
+
return value if isinstance(value, dict) else {"version": 1, "profiles": {}}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_credentials(value: dict[str, Any]) -> None:
|
|
60
|
+
path = credentials_path()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
62
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix="credentials-", dir=path.parent)
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
65
|
+
json.dump(value, handle, indent=2, sort_keys=True)
|
|
66
|
+
handle.write("\n")
|
|
67
|
+
os.chmod(temporary_name, 0o600)
|
|
68
|
+
os.replace(temporary_name, path)
|
|
69
|
+
os.chmod(path, 0o600)
|
|
70
|
+
finally:
|
|
71
|
+
if os.path.exists(temporary_name):
|
|
72
|
+
os.unlink(temporary_name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_profile(
|
|
76
|
+
profile: str,
|
|
77
|
+
*,
|
|
78
|
+
base_url: str,
|
|
79
|
+
api_key: str,
|
|
80
|
+
organization_id: str | None = None,
|
|
81
|
+
api_key_id: int | None = None,
|
|
82
|
+
expires_at: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
payload = _read_credentials()
|
|
85
|
+
profiles = payload.setdefault("profiles", {})
|
|
86
|
+
previous = profiles.get(profile) if isinstance(profiles.get(profile), dict) else {}
|
|
87
|
+
profiles[profile] = {
|
|
88
|
+
**previous,
|
|
89
|
+
"base_url": normalize_url(base_url),
|
|
90
|
+
"api_key": api_key,
|
|
91
|
+
"organization_id": organization_id,
|
|
92
|
+
"api_key_id": api_key_id,
|
|
93
|
+
"expires_at": expires_at,
|
|
94
|
+
}
|
|
95
|
+
_write_credentials(payload)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def delete_profile(profile: str) -> bool:
|
|
99
|
+
payload = _read_credentials()
|
|
100
|
+
removed = payload.setdefault("profiles", {}).pop(profile, None) is not None
|
|
101
|
+
if removed:
|
|
102
|
+
_write_credentials(payload)
|
|
103
|
+
return removed
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_agent_ingest_token(profile: str, agent_id: str, token: str) -> None:
|
|
107
|
+
payload = _read_credentials()
|
|
108
|
+
entry = payload.setdefault("profiles", {}).get(profile)
|
|
109
|
+
if not isinstance(entry, dict):
|
|
110
|
+
raise typer.BadParameter(f"CLI profile '{profile}' is not logged in")
|
|
111
|
+
agents = entry.setdefault("agents", {})
|
|
112
|
+
agents[agent_id] = {"otlp_token": token}
|
|
113
|
+
_write_credentials(payload)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Credentials:
|
|
118
|
+
profile: str
|
|
119
|
+
base_url: str
|
|
120
|
+
api_key: str
|
|
121
|
+
organization_id: str | None
|
|
122
|
+
otlp_token: str | None = None
|
|
123
|
+
api_key_id: int | None = None
|
|
124
|
+
expires_at: str | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def stored_profile_credentials(profile: str = "default") -> Credentials | None:
|
|
128
|
+
"""Return one stored profile without allowing environment variables to replace its key."""
|
|
129
|
+
entry = _read_credentials().get("profiles", {}).get(profile)
|
|
130
|
+
if not isinstance(entry, dict) or not entry.get("api_key"):
|
|
131
|
+
return None
|
|
132
|
+
raw_key_id = entry.get("api_key_id")
|
|
133
|
+
return Credentials(
|
|
134
|
+
profile=profile,
|
|
135
|
+
base_url=normalize_url(str(entry.get("base_url") or DEFAULT_BASE_URL)),
|
|
136
|
+
api_key=str(entry["api_key"]),
|
|
137
|
+
organization_id=entry.get("organization_id"),
|
|
138
|
+
api_key_id=raw_key_id if isinstance(raw_key_id, int) else None,
|
|
139
|
+
expires_at=str(entry["expires_at"]) if entry.get("expires_at") else None,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_credentials(profile: str = "default", agent_id: str | None = None) -> Credentials:
|
|
144
|
+
payload = _read_credentials()
|
|
145
|
+
entry = payload.get("profiles", {}).get(profile)
|
|
146
|
+
env_key = os.getenv("HALIOS_API_KEY")
|
|
147
|
+
if not isinstance(entry, dict) and not env_key:
|
|
148
|
+
raise typer.BadParameter("Not logged in. Run `halios auth login` or set HALIOS_API_KEY.")
|
|
149
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
150
|
+
base_url = normalize_url(
|
|
151
|
+
os.getenv("HALIOS_BASE_URL") or str(entry.get("base_url") or DEFAULT_BASE_URL)
|
|
152
|
+
)
|
|
153
|
+
# INTENT: CI runners need both control-plane and agent-scoped ingest credentials without
|
|
154
|
+
# writing a persistent profile to the ephemeral filesystem.
|
|
155
|
+
token = os.getenv("HALIOS_OTLP_TOKEN")
|
|
156
|
+
if agent_id:
|
|
157
|
+
agent_entry = (entry.get("agents") or {}).get(agent_id)
|
|
158
|
+
if not token and isinstance(agent_entry, dict):
|
|
159
|
+
token = agent_entry.get("otlp_token")
|
|
160
|
+
return Credentials(
|
|
161
|
+
profile=profile,
|
|
162
|
+
base_url=base_url,
|
|
163
|
+
api_key=env_key or str(entry.get("api_key") or ""),
|
|
164
|
+
organization_id=entry.get("organization_id"),
|
|
165
|
+
otlp_token=token,
|
|
166
|
+
api_key_id=entry.get("api_key_id") if isinstance(entry.get("api_key_id"), int) else None,
|
|
167
|
+
expires_at=str(entry["expires_at"]) if entry.get("expires_at") else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ApiClient:
|
|
172
|
+
def __init__(self, credentials: Credentials):
|
|
173
|
+
from ._version import __version__
|
|
174
|
+
|
|
175
|
+
self.credentials = credentials
|
|
176
|
+
self.client = httpx.Client(
|
|
177
|
+
base_url=credentials.base_url,
|
|
178
|
+
headers={
|
|
179
|
+
"Authorization": f"Bearer {credentials.api_key}",
|
|
180
|
+
"User-Agent": f"haliosai-cli/{__version__}",
|
|
181
|
+
},
|
|
182
|
+
timeout=30.0,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
186
|
+
response = self.client.request(method, path, **kwargs)
|
|
187
|
+
if response.is_error:
|
|
188
|
+
try:
|
|
189
|
+
detail = response.json().get("detail", response.text)
|
|
190
|
+
except (ValueError, AttributeError):
|
|
191
|
+
detail = response.text
|
|
192
|
+
raise ApiError(response.status_code, detail)
|
|
193
|
+
return response.json() if response.content else None
|
|
194
|
+
|
|
195
|
+
def close(self) -> None:
|
|
196
|
+
self.client.close()
|
|
197
|
+
|
|
198
|
+
def __enter__(self) -> "ApiClient":
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def __exit__(self, *_args: Any) -> None:
|
|
202
|
+
self.close()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_yaml(path: pathlib.Path) -> dict[str, Any]:
|
|
206
|
+
import yaml
|
|
207
|
+
|
|
208
|
+
if not path.exists():
|
|
209
|
+
raise typer.BadParameter(f"Missing required file: {path}")
|
|
210
|
+
value = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
211
|
+
if not isinstance(value, dict):
|
|
212
|
+
raise typer.BadParameter(f"YAML root must be an object: {path}")
|
|
213
|
+
return value
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def write_yaml(path: pathlib.Path, value: dict[str, Any]) -> None:
|
|
217
|
+
import yaml
|
|
218
|
+
|
|
219
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f"{path.name}-", dir=path.parent)
|
|
221
|
+
try:
|
|
222
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
223
|
+
yaml.safe_dump(value, handle, sort_keys=False)
|
|
224
|
+
os.replace(temporary_name, path)
|
|
225
|
+
finally:
|
|
226
|
+
if os.path.exists(temporary_name):
|
|
227
|
+
os.unlink(temporary_name)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def atomic_write_text(path: pathlib.Path, content: str) -> None:
|
|
231
|
+
"""Replace one text file atomically, preserving its existing permission bits."""
|
|
232
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f"{path.name}-", dir=path.parent)
|
|
234
|
+
try:
|
|
235
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
236
|
+
handle.write(content)
|
|
237
|
+
handle.flush()
|
|
238
|
+
os.fsync(handle.fileno())
|
|
239
|
+
if path.exists():
|
|
240
|
+
os.chmod(temporary_name, path.stat().st_mode & 0o777)
|
|
241
|
+
os.replace(temporary_name, path)
|
|
242
|
+
finally:
|
|
243
|
+
if os.path.exists(temporary_name):
|
|
244
|
+
os.unlink(temporary_name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def evaluation_suite_digest(eval_plan: dict[str, Any], scenarios: dict[str, Any]) -> str:
|
|
248
|
+
canonical = json.dumps(
|
|
249
|
+
{"eval": eval_plan, "scenarios": scenarios},
|
|
250
|
+
sort_keys=True,
|
|
251
|
+
separators=(",", ":"),
|
|
252
|
+
ensure_ascii=False,
|
|
253
|
+
)
|
|
254
|
+
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_suite_checkout(
|
|
258
|
+
root: pathlib.Path,
|
|
259
|
+
*,
|
|
260
|
+
eval_plan: dict[str, Any],
|
|
261
|
+
scenarios: dict[str, Any],
|
|
262
|
+
revision: int,
|
|
263
|
+
digest: str | None,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Replace both canonical YAML values and their shared checkout revision."""
|
|
266
|
+
halios_dir = root / ".halios"
|
|
267
|
+
write_yaml(halios_dir / "eval.yml", eval_plan)
|
|
268
|
+
write_yaml(halios_dir / "scenarios.yml", scenarios)
|
|
269
|
+
config_path = halios_dir / "config.toml"
|
|
270
|
+
content = config_path.read_text(encoding="utf-8")
|
|
271
|
+
suite_block = (
|
|
272
|
+
f'[suite]\nrevision = {revision}\ndigest = "{str(digest or "").replace(chr(34), "")}"\n'
|
|
273
|
+
)
|
|
274
|
+
if "[suite]" in content:
|
|
275
|
+
content = re.sub(r"(?ms)^\[suite\]\n.*?(?=^\[|\Z)", suite_block + "\n", content)
|
|
276
|
+
else:
|
|
277
|
+
content = content.rstrip() + "\n\n" + suite_block
|
|
278
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix="config-", dir=halios_dir)
|
|
279
|
+
try:
|
|
280
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
281
|
+
handle.write(content)
|
|
282
|
+
os.replace(temporary_name, config_path)
|
|
283
|
+
finally:
|
|
284
|
+
if os.path.exists(temporary_name):
|
|
285
|
+
os.unlink(temporary_name)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def preserve_suite_recovery(
|
|
289
|
+
*,
|
|
290
|
+
agent_id: str,
|
|
291
|
+
eval_plan: dict[str, Any],
|
|
292
|
+
scenarios: dict[str, Any],
|
|
293
|
+
) -> pathlib.Path:
|
|
294
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
295
|
+
parent = credentials_path().parent / "recovery" / agent_id
|
|
296
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
recovery = pathlib.Path(tempfile.mkdtemp(prefix=f"{timestamp}-", dir=parent))
|
|
298
|
+
write_yaml(recovery / "eval.yml", eval_plan)
|
|
299
|
+
write_yaml(recovery / "scenarios.yml", scenarios)
|
|
300
|
+
return recovery
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_project_config(root: pathlib.Path | None = None) -> tuple[pathlib.Path, dict[str, Any]]:
|
|
304
|
+
project_root = (root or pathlib.Path.cwd()).resolve()
|
|
305
|
+
path = project_root / ".halios" / "config.toml"
|
|
306
|
+
if not path.exists():
|
|
307
|
+
raise typer.BadParameter("No .halios/config.toml. Run `halios project init --agent ...`.")
|
|
308
|
+
try:
|
|
309
|
+
import tomllib
|
|
310
|
+
except ImportError: # pragma: no cover - Python 3.10
|
|
311
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
312
|
+
with path.open("rb") as handle:
|
|
313
|
+
config = tomllib.load(handle)
|
|
314
|
+
return project_root, config
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def git_provenance(root: pathlib.Path) -> dict[str, Any]:
|
|
318
|
+
from ._version import __version__
|
|
319
|
+
|
|
320
|
+
def run(*args: str) -> str | None:
|
|
321
|
+
result = subprocess.run(
|
|
322
|
+
["git", *args], cwd=root, text=True, capture_output=True, check=False
|
|
323
|
+
)
|
|
324
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
325
|
+
|
|
326
|
+
commit_sha = run("rev-parse", "HEAD")
|
|
327
|
+
branch = run("branch", "--show-current")
|
|
328
|
+
repository = run("config", "--get", "remote.origin.url")
|
|
329
|
+
tracked_dirty = any(
|
|
330
|
+
subprocess.run(["git", *args], cwd=root, check=False).returncode != 0
|
|
331
|
+
for args in (("diff", "--quiet"), ("diff", "--cached", "--quiet"))
|
|
332
|
+
)
|
|
333
|
+
definition_paths = [
|
|
334
|
+
".halios/config.toml",
|
|
335
|
+
".halios/eval.yml",
|
|
336
|
+
".halios/scenarios.yml",
|
|
337
|
+
]
|
|
338
|
+
definitions_tracked = (
|
|
339
|
+
subprocess.run(
|
|
340
|
+
["git", "ls-files", "--error-unmatch", *definition_paths],
|
|
341
|
+
cwd=root,
|
|
342
|
+
stdout=subprocess.DEVNULL,
|
|
343
|
+
stderr=subprocess.DEVNULL,
|
|
344
|
+
check=False,
|
|
345
|
+
).returncode
|
|
346
|
+
== 0
|
|
347
|
+
)
|
|
348
|
+
github_run_id = os.getenv("GITHUB_RUN_ID")
|
|
349
|
+
github_repository = os.getenv("GITHUB_REPOSITORY")
|
|
350
|
+
github_server = os.getenv("GITHUB_SERVER_URL")
|
|
351
|
+
pipeline_url = os.getenv("CI_PIPELINE_URL")
|
|
352
|
+
if github_run_id and github_repository and github_server:
|
|
353
|
+
pipeline_url = f"{github_server}/{github_repository}/actions/runs/{github_run_id}"
|
|
354
|
+
return {
|
|
355
|
+
"repository": repository,
|
|
356
|
+
"branch": branch,
|
|
357
|
+
"commit_sha": commit_sha,
|
|
358
|
+
"dirty_worktree": tracked_dirty or not definitions_tracked,
|
|
359
|
+
"actor": os.getenv("GITHUB_ACTOR") or os.getenv("GITLAB_USER_LOGIN") or os.getenv("USER"),
|
|
360
|
+
"pipeline_id": github_run_id or os.getenv("CI_PIPELINE_ID"),
|
|
361
|
+
"pipeline_url": pipeline_url,
|
|
362
|
+
"job_id": os.getenv("GITHUB_JOB") or os.getenv("CI_JOB_ID"),
|
|
363
|
+
"environment": os.getenv("DEPLOYMENT_ENVIRONMENT")
|
|
364
|
+
or ("ci" if os.getenv("CI") else "local"),
|
|
365
|
+
"client_name": os.getenv("HALIOS_CLIENT_NAME") or "halios-cli",
|
|
366
|
+
"client_version": __version__,
|
|
367
|
+
# INTENT: Device identity is opt-in. Do not turn a raw hostname into a
|
|
368
|
+
# durable product identifier or leak it into shared evaluation data.
|
|
369
|
+
"source_label": os.getenv("HALIOS_SOURCE_LABEL"),
|
|
370
|
+
"service_name": os.getenv("OTEL_SERVICE_NAME"),
|
|
371
|
+
"service_instance_id": os.getenv("OTEL_SERVICE_INSTANCE_ID")
|
|
372
|
+
or os.getenv("HALIOS_SOURCE_ID"),
|
|
373
|
+
}
|
halios_cli/cli_trace.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Read-only trace evidence commands for coding agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .cli_support import ApiClient, load_project_config, resolve_credentials
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Inspect trace evidence and production failures.", no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _context():
|
|
16
|
+
_root, config = load_project_config()
|
|
17
|
+
agent_id = str((config.get("agent") or {}).get("id") or "")
|
|
18
|
+
credentials = resolve_credentials(str(config.get("profile") or "default"), agent_id)
|
|
19
|
+
return agent_id, credentials
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _emit(value: object, json_output: bool) -> None:
|
|
23
|
+
if json_output:
|
|
24
|
+
typer.echo(json.dumps(value, indent=2, sort_keys=True, default=str))
|
|
25
|
+
else:
|
|
26
|
+
items = value.get("data", value) if isinstance(value, dict) else value
|
|
27
|
+
if isinstance(items, list):
|
|
28
|
+
for item in items:
|
|
29
|
+
if isinstance(item, dict):
|
|
30
|
+
typer.echo(
|
|
31
|
+
"\t".join(
|
|
32
|
+
str(item.get(key) or "") for key in ("trace_id", "status", "created_at")
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
typer.echo(json.dumps(value, indent=2, default=str))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("list")
|
|
40
|
+
def list_traces(
|
|
41
|
+
environment: str | None = typer.Option(None, "--environment"),
|
|
42
|
+
limit: int = typer.Option(50, "--limit", min=1, max=100),
|
|
43
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
44
|
+
) -> None:
|
|
45
|
+
agent_id, credentials = _context()
|
|
46
|
+
params = {"agent_id": agent_id, "limit": limit}
|
|
47
|
+
if environment:
|
|
48
|
+
params["traffic_scope"] = environment
|
|
49
|
+
with ApiClient(credentials) as api:
|
|
50
|
+
result = api.request("GET", "/api/v1/traces", params=params)
|
|
51
|
+
_emit(result, json_output)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("show")
|
|
55
|
+
def show(
|
|
56
|
+
trace_id: str,
|
|
57
|
+
include: str = typer.Option("spans,checks", "--include"),
|
|
58
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
59
|
+
) -> None:
|
|
60
|
+
_agent_id, credentials = _context()
|
|
61
|
+
with ApiClient(credentials) as api:
|
|
62
|
+
result = api.request("GET", f"/api/v1/traces/{trace_id}")
|
|
63
|
+
allowed = {item.strip() for item in include.split(",") if item.strip()}
|
|
64
|
+
if isinstance(result, dict):
|
|
65
|
+
if "spans" not in allowed:
|
|
66
|
+
result.pop("spans", None)
|
|
67
|
+
if "checks" not in allowed:
|
|
68
|
+
result.pop("check_executions", None)
|
|
69
|
+
_emit(result, json_output)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("failures")
|
|
73
|
+
def failures(
|
|
74
|
+
environment: str = typer.Option("production", "--environment"),
|
|
75
|
+
limit: int = typer.Option(100, "--limit", min=1, max=100),
|
|
76
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""List failed evaluator evidence for the selected environment."""
|
|
79
|
+
agent_id, credentials = _context()
|
|
80
|
+
with ApiClient(credentials) as api:
|
|
81
|
+
result = api.request(
|
|
82
|
+
"GET",
|
|
83
|
+
f"/api/v1/agents/{agent_id}/check-executions",
|
|
84
|
+
params={
|
|
85
|
+
"mode": "evaluator",
|
|
86
|
+
"evaluation_context": environment,
|
|
87
|
+
"limit": limit,
|
|
88
|
+
"include_progress": False,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
items = result.get("data") or []
|
|
92
|
+
result["data"] = [
|
|
93
|
+
item
|
|
94
|
+
for item in items
|
|
95
|
+
if item.get("passed") is False
|
|
96
|
+
or item.get("triggered") is True
|
|
97
|
+
or item.get("status") in {"failed", "error"}
|
|
98
|
+
or item.get("error")
|
|
99
|
+
]
|
|
100
|
+
_emit(result, json_output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("cluster")
|
|
104
|
+
def cluster(
|
|
105
|
+
environment: str = typer.Option("production", "--environment"),
|
|
106
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Cluster recent evaluator failures in one evidence environment."""
|
|
109
|
+
agent_id, credentials = _context()
|
|
110
|
+
with ApiClient(credentials) as api:
|
|
111
|
+
result = api.request(
|
|
112
|
+
"GET",
|
|
113
|
+
"/api/v1/scenarios/failure-clusters",
|
|
114
|
+
params={"agent_id": agent_id, "evaluation_context": environment},
|
|
115
|
+
)
|
|
116
|
+
_emit(result, json_output)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command("verify")
|
|
120
|
+
def verify(
|
|
121
|
+
trace_id: str,
|
|
122
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Fail closed unless a stored runtime trace has usable standard OTel evidence."""
|
|
125
|
+
_agent_id, credentials = _context()
|
|
126
|
+
with ApiClient(credentials) as api:
|
|
127
|
+
detail = api.request("GET", f"/api/v1/traces/{trace_id}")
|
|
128
|
+
spans = detail.get("spans") or []
|
|
129
|
+
issues: list[str] = []
|
|
130
|
+
if not re.fullmatch(r"[0-9a-f]{32}", trace_id) or trace_id == "0" * 32:
|
|
131
|
+
issues.append("trace id is not a valid non-zero W3C trace id")
|
|
132
|
+
if detail.get("trace_id") != trace_id:
|
|
133
|
+
issues.append("trace identity does not match")
|
|
134
|
+
if not spans:
|
|
135
|
+
issues.append("trace contains no spans")
|
|
136
|
+
span_ids = {str(span.get("span_id")) for span in spans if isinstance(span, dict)}
|
|
137
|
+
roots = [span for span in spans if isinstance(span, dict) and not span.get("parent_span_id")]
|
|
138
|
+
if len(roots) != 1:
|
|
139
|
+
issues.append(f"trace must contain exactly one root span; found {len(roots)}")
|
|
140
|
+
for span in spans:
|
|
141
|
+
if not isinstance(span, dict):
|
|
142
|
+
issues.append("trace contains a malformed span")
|
|
143
|
+
continue
|
|
144
|
+
span_id = str(span.get("span_id") or "")
|
|
145
|
+
if not re.fullmatch(r"[0-9a-f]{16}", span_id) or span_id == "0" * 16:
|
|
146
|
+
issues.append(f"span {span_id or '<missing>'} has an invalid W3C span id")
|
|
147
|
+
parent = span.get("parent_span_id")
|
|
148
|
+
if parent and str(parent) not in span_ids:
|
|
149
|
+
issues.append(f"span {span.get('span_id')} has a missing parent")
|
|
150
|
+
if not span.get("ended_at"):
|
|
151
|
+
issues.append(f"span {span.get('span_id')} has no end time")
|
|
152
|
+
has_semantic_content = any(
|
|
153
|
+
isinstance(span, dict) and (span.get("input") or span.get("output")) for span in spans
|
|
154
|
+
)
|
|
155
|
+
if not has_semantic_content:
|
|
156
|
+
issues.append("trace has no captured input or output evidence")
|
|
157
|
+
attributes = [span.get("attributes") or {} for span in spans if isinstance(span, dict)]
|
|
158
|
+
if not any(value.get("resource.service.name") for value in attributes):
|
|
159
|
+
issues.append("trace has no service.name resource identity")
|
|
160
|
+
if not any(
|
|
161
|
+
value.get("resource.deployment.environment.name")
|
|
162
|
+
or value.get("resource.deployment.environment")
|
|
163
|
+
for value in attributes
|
|
164
|
+
):
|
|
165
|
+
issues.append("trace has no deployment.environment.name resource identity")
|
|
166
|
+
result = {
|
|
167
|
+
"trace_id": trace_id,
|
|
168
|
+
"verified": not issues,
|
|
169
|
+
"span_count": len(spans),
|
|
170
|
+
"root_count": len(roots),
|
|
171
|
+
"issues": issues,
|
|
172
|
+
}
|
|
173
|
+
_emit(result, json_output)
|
|
174
|
+
if issues:
|
|
175
|
+
raise typer.Exit(2)
|
halios_cli/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged strict schemas used by the Halios CLI."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.halios.ai/eval.schema.json",
|
|
4
|
+
"title": "Halios evaluation plan",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "name", "goals", "risks", "checks", "reliability_bar"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": 1 },
|
|
10
|
+
"name": { "$ref": "#/$defs/nonEmptyString" },
|
|
11
|
+
"goals": { "$ref": "#/$defs/nonEmptyStringList" },
|
|
12
|
+
"risks": { "$ref": "#/$defs/nonEmptyStringList" },
|
|
13
|
+
"checks": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"minItems": 1,
|
|
16
|
+
"items": { "$ref": "#/$defs/check" }
|
|
17
|
+
},
|
|
18
|
+
"reliability_bar": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"additionalProperties": false,
|
|
21
|
+
"required": ["min_pass_rate", "hard_gates_must_pass"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"min_pass_rate": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
24
|
+
"hard_gates_must_pass": { "type": "boolean" }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"$defs": {
|
|
29
|
+
"nonEmptyString": { "type": "string", "minLength": 1 },
|
|
30
|
+
"stableId": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"minLength": 1,
|
|
33
|
+
"maxLength": 120,
|
|
34
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
35
|
+
},
|
|
36
|
+
"nonEmptyStringList": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"minItems": 1,
|
|
39
|
+
"items": { "$ref": "#/$defs/nonEmptyString" }
|
|
40
|
+
},
|
|
41
|
+
"rule": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"additionalProperties": false,
|
|
44
|
+
"required": ["id", "type", "config"],
|
|
45
|
+
"properties": {
|
|
46
|
+
"id": { "$ref": "#/$defs/stableId" },
|
|
47
|
+
"name": { "$ref": "#/$defs/nonEmptyString" },
|
|
48
|
+
"type": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"enum": [
|
|
51
|
+
"classifier", "contains", "equals", "exists", "fuzzy", "greater_than",
|
|
52
|
+
"is_empty", "is_null", "json_schema", "less_than", "llm_judge", "not_empty",
|
|
53
|
+
"not_null", "one_of", "regex"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"field": { "$ref": "#/$defs/nonEmptyString" },
|
|
57
|
+
"operator": { "type": "string", "enum": ["AND", "OR"] },
|
|
58
|
+
"key": { "type": ["string", "null"] },
|
|
59
|
+
"config": { "type": "object" }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"evaluationConfig": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"additionalProperties": false,
|
|
65
|
+
"required": ["task_name", "score_threshold"],
|
|
66
|
+
"properties": {
|
|
67
|
+
"task_name": { "$ref": "#/$defs/nonEmptyString" },
|
|
68
|
+
"task_slug": { "$ref": "#/$defs/nonEmptyString" },
|
|
69
|
+
"aggregate_method": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": ["average", "weighted_average", "max", "min"]
|
|
72
|
+
},
|
|
73
|
+
"score_threshold": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
74
|
+
"tags": { "type": "array", "items": { "type": "string" } },
|
|
75
|
+
"per_event": { "type": "boolean" }
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"check": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"additionalProperties": false,
|
|
81
|
+
"required": ["id", "name", "category", "target", "scope", "pass_logic", "rules", "evaluation_config"],
|
|
82
|
+
"properties": {
|
|
83
|
+
"id": { "$ref": "#/$defs/stableId" },
|
|
84
|
+
"name": { "$ref": "#/$defs/nonEmptyString" },
|
|
85
|
+
"description": { "type": ["string", "null"] },
|
|
86
|
+
"category": { "type": "string", "enum": ["safety", "quality", "compliance", "custom"] },
|
|
87
|
+
"protected": { "type": "boolean" },
|
|
88
|
+
"target": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"enum": ["user_message", "assistant_message", "tool_usage", "full_conversation"]
|
|
91
|
+
},
|
|
92
|
+
"scope": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"enum": ["all", "last_n", "first_n", "after_tool", "tool_name", "input_arguments", "output_values", "tool_call_context", "entire"]
|
|
95
|
+
},
|
|
96
|
+
"tool_name": { "$ref": "#/$defs/nonEmptyString" },
|
|
97
|
+
"field": { "$ref": "#/$defs/nonEmptyString" },
|
|
98
|
+
"scope_params": { "type": "object" },
|
|
99
|
+
"pass_logic": { "type": "string", "enum": ["all", "any"] },
|
|
100
|
+
"rules": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/rule" } },
|
|
101
|
+
"evaluation_config": { "$ref": "#/$defs/evaluationConfig" }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|