harnessweaver-cli 0.7.1__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.
Files changed (84) hide show
  1. harnessweaver_cli/__init__.py +7 -0
  2. harnessweaver_cli/_load.py +119 -0
  3. harnessweaver_cli/_yaml.py +49 -0
  4. harnessweaver_cli/add.py +238 -0
  5. harnessweaver_cli/dev.py +141 -0
  6. harnessweaver_cli/doctor.py +185 -0
  7. harnessweaver_cli/evals.py +276 -0
  8. harnessweaver_cli/gateway.py +301 -0
  9. harnessweaver_cli/main.py +62 -0
  10. harnessweaver_cli/plugins.py +105 -0
  11. harnessweaver_cli/policy.py +143 -0
  12. harnessweaver_cli/py.typed +0 -0
  13. harnessweaver_cli/replay.py +75 -0
  14. harnessweaver_cli/scaffold.py +158 -0
  15. harnessweaver_cli/sidecar.py +48 -0
  16. harnessweaver_cli/spec.py +160 -0
  17. harnessweaver_cli/templates/_dev/compose.yaml +183 -0
  18. harnessweaver_cli/templates/_dev/otel-collector.yaml +25 -0
  19. harnessweaver_cli/templates/_plugin/README.md +26 -0
  20. harnessweaver_cli/templates/_plugin/pyproject.toml +26 -0
  21. harnessweaver_cli/templates/_plugin/src/__ns___harness/__init__.py +20 -0
  22. harnessweaver_cli/templates/_plugin/src/__ns___harness/guards.py +48 -0
  23. harnessweaver_cli/templates/_plugin/src/__ns___harness/py.typed +0 -0
  24. harnessweaver_cli/templates/_plugin/tests/test_guards.py +26 -0
  25. harnessweaver_cli/templates/agent/README.md +18 -0
  26. harnessweaver_cli/templates/agent/app/__init__.py +1 -0
  27. harnessweaver_cli/templates/agent/app/agent.py +12 -0
  28. harnessweaver_cli/templates/agent/app/main.py +19 -0
  29. harnessweaver_cli/templates/agent/app/tools.py +28 -0
  30. harnessweaver_cli/templates/agent/evals/redteam.yaml +17 -0
  31. harnessweaver_cli/templates/agent/harness.offline.yaml +16 -0
  32. harnessweaver_cli/templates/agent/harness.yaml +38 -0
  33. harnessweaver_cli/templates/agent/pyproject.toml +13 -0
  34. harnessweaver_cli/templates/agent/tests/test_agent.py +54 -0
  35. harnessweaver_cli/templates/agent/tools/lookup_order.tool.yaml +15 -0
  36. harnessweaver_cli/templates/agent/tools/refund_payment.tool.yaml +17 -0
  37. harnessweaver_cli/templates/chat/README.md +15 -0
  38. harnessweaver_cli/templates/chat/app/__init__.py +1 -0
  39. harnessweaver_cli/templates/chat/app/chat.py +32 -0
  40. harnessweaver_cli/templates/chat/app/main.py +33 -0
  41. harnessweaver_cli/templates/chat/evals/golden.yaml +10 -0
  42. harnessweaver_cli/templates/chat/harness.offline.yaml +12 -0
  43. harnessweaver_cli/templates/chat/harness.yaml +39 -0
  44. harnessweaver_cli/templates/chat/pyproject.toml +13 -0
  45. harnessweaver_cli/templates/chat/tests/test_chat.py +63 -0
  46. harnessweaver_cli/templates/extraction/README.md +16 -0
  47. harnessweaver_cli/templates/extraction/app/__init__.py +1 -0
  48. harnessweaver_cli/templates/extraction/app/main.py +21 -0
  49. harnessweaver_cli/templates/extraction/app/models.py +20 -0
  50. harnessweaver_cli/templates/extraction/evals/golden.yaml +12 -0
  51. harnessweaver_cli/templates/extraction/harness.offline.yaml +18 -0
  52. harnessweaver_cli/templates/extraction/harness.yaml +33 -0
  53. harnessweaver_cli/templates/extraction/prompts/extract@1.md +7 -0
  54. harnessweaver_cli/templates/extraction/pyproject.toml +13 -0
  55. harnessweaver_cli/templates/extraction/tests/test_extract.py +56 -0
  56. harnessweaver_cli/templates/rag/README.md +17 -0
  57. harnessweaver_cli/templates/rag/app/__init__.py +1 -0
  58. harnessweaver_cli/templates/rag/app/flow.py +29 -0
  59. harnessweaver_cli/templates/rag/app/main.py +19 -0
  60. harnessweaver_cli/templates/rag/data/kb.yaml +13 -0
  61. harnessweaver_cli/templates/rag/evals/golden.yaml +14 -0
  62. harnessweaver_cli/templates/rag/harness.offline.yaml +13 -0
  63. harnessweaver_cli/templates/rag/harness.yaml +50 -0
  64. harnessweaver_cli/templates/rag/prompts/answer@1.md +9 -0
  65. harnessweaver_cli/templates/rag/pyproject.toml +13 -0
  66. harnessweaver_cli/templates/rag/tests/test_answer.py +50 -0
  67. harnessweaver_cli/templates/workflow/README.md +16 -0
  68. harnessweaver_cli/templates/workflow/app/__init__.py +1 -0
  69. harnessweaver_cli/templates/workflow/app/main.py +20 -0
  70. harnessweaver_cli/templates/workflow/app/steps.py +49 -0
  71. harnessweaver_cli/templates/workflow/evals/golden.yaml +9 -0
  72. harnessweaver_cli/templates/workflow/harness.offline.yaml +12 -0
  73. harnessweaver_cli/templates/workflow/harness.yaml +39 -0
  74. harnessweaver_cli/templates/workflow/prompts/classify@1.md +4 -0
  75. harnessweaver_cli/templates/workflow/prompts/reply@1.md +5 -0
  76. harnessweaver_cli/templates/workflow/prompts/summarize@1.md +3 -0
  77. harnessweaver_cli/templates/workflow/pyproject.toml +13 -0
  78. harnessweaver_cli/templates/workflow/tests/test_pipeline.py +32 -0
  79. harnessweaver_cli/tools.py +72 -0
  80. harnessweaver_cli-0.7.1.dist-info/METADATA +83 -0
  81. harnessweaver_cli-0.7.1.dist-info/RECORD +84 -0
  82. harnessweaver_cli-0.7.1.dist-info/WHEEL +4 -0
  83. harnessweaver_cli-0.7.1.dist-info/entry_points.txt +2 -0
  84. harnessweaver_cli-0.7.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,185 @@
1
+ """``harnessweaver doctor``: what a spec reaches and what to look at next.
2
+
3
+ Reports the adoption level, unreachable hook points (as load errors), duplicate
4
+ owners of retries (R5), unpinned profiles, tool manifests that aren't pinned or
5
+ changed since they were (S2), guards still in shadow mode (R8), content capture
6
+ (R9) and plugin installation problems.
7
+ """
8
+
9
+ import json
10
+ from dataclasses import asdict, dataclass
11
+ from pathlib import Path
12
+ from typing import Annotated, Any, Literal
13
+
14
+ import typer
15
+
16
+ from harnessweaver import Harness
17
+ from harnessweaver.core.errors import HarnessError
18
+ from harnessweaver.core.guards import GuardAction
19
+ from harnessweaver.core.registry import PluginCatalog
20
+ from harnessweaver.modules.guardrails import configured_guards
21
+ from harnessweaver.modules.tools import load_manifests
22
+ from harnessweaver.spec import REQUIREMENTS, Level
23
+ from harnessweaver.spec.models import HarnessSpec
24
+ from harnessweaver.spec.sources import parse_ref
25
+ from harnessweaver_cli._load import spec_path
26
+
27
+ __all__ = ["Finding", "diagnose", "doctor"]
28
+
29
+ type Severity = Literal["ok", "warn", "fail"]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Finding:
34
+ severity: Severity
35
+ check: str
36
+ message: str
37
+
38
+
39
+ def _level(harness: Harness) -> list[Finding]:
40
+ report = harness.spec.level
41
+ tier = harness.spec.spec.metadata.risk_tier
42
+ level, required = report.level.value, report.required.value
43
+ findings = [
44
+ Finding("ok", "level", f"adoption level {level}; risk tier {tier!r} requires {required}")
45
+ ]
46
+ following = next((lvl for lvl in Level if lvl.rank == report.level.rank + 1), None)
47
+ if following is not None:
48
+ missing = [REQUIREMENTS[code] for code in report.missing_for(following)]
49
+ if missing:
50
+ findings.append(
51
+ Finding("ok", "next level", f"to reach {following.value}: {'; '.join(missing)}")
52
+ )
53
+ return findings
54
+
55
+
56
+ def _owners(harness: Harness) -> list[Finding]:
57
+ models = harness.spec.spec.models
58
+ if models.provider is None:
59
+ return []
60
+ info = harness.registry.provider("ModelProvider", models.provider)
61
+ settings: Any = models.provider_settings()
62
+ config = info.config.model_validate(settings) if info.config is not None else None
63
+ base_url = getattr(config, "base_url", None)
64
+ retries = getattr(config, "max_retries", 0) or 0
65
+ if base_url and retries > 0:
66
+ return [
67
+ Finding(
68
+ "warn",
69
+ "retries",
70
+ f"{models.provider} retries {retries} time(s) behind the gateway at {base_url}; "
71
+ "let the gateway own retries and set max_retries: 0 (R5)",
72
+ )
73
+ ]
74
+ return [Finding("ok", "retries", "retries have one owner")]
75
+
76
+
77
+ def _pins(spec: HarnessSpec) -> list[Finding]:
78
+ findings: list[Finding] = []
79
+ for text in spec.extends:
80
+ ref = parse_ref(text)
81
+ plugin_profile = ref.scheme == "profile" and "." in ref.name
82
+ if (ref.scheme == "org" or plugin_profile) and ref.range is None:
83
+ findings.append(
84
+ Finding("warn", "pins", f"{text} isn't pinned; use a range like {text}@^1 (V1)")
85
+ )
86
+ return findings or [Finding("ok", "pins", "every org and plugin profile is pinned")]
87
+
88
+
89
+ def _shadow(spec: HarnessSpec) -> list[Finding]:
90
+ shadow = [
91
+ name
92
+ for name, g in configured_guards(spec)
93
+ if g.mode == "log_only" or g.action is GuardAction.LOG_ONLY
94
+ ]
95
+ if not shadow:
96
+ return [Finding("ok", "shadow guards", "no guards in shadow mode")]
97
+ return [
98
+ Finding(
99
+ "warn",
100
+ "shadow guards",
101
+ f"{name} is in shadow mode; promote it to enforce once evals back it (R8)",
102
+ )
103
+ for name in shadow
104
+ ]
105
+
106
+
107
+ def _tools(harness: Harness) -> list[Finding]:
108
+ """Every manifest should pin its definition; a stale pin blocks the tool's calls (S2)."""
109
+ spec = harness.spec.spec
110
+ if not spec.tools.manifests:
111
+ return []
112
+ base = harness.spec.path.parent if harness.spec.path is not None else Path.cwd()
113
+ findings: list[Finding] = []
114
+ manifests = load_manifests(spec.tools.manifests, base)
115
+ for manifest in manifests:
116
+ pinned, computed = manifest.spec.definition_digest, manifest.computed_digest()
117
+ if pinned is None:
118
+ message = f"tool {manifest.name} isn't pinned; add definition_digest: {computed} (S2)"
119
+ findings.append(Finding("warn", "tools", message))
120
+ elif pinned != computed:
121
+ message = (
122
+ f"tool {manifest.name} changed since it was pinned, so its calls are blocked; "
123
+ f"review it, then set definition_digest: {computed} (S2)"
124
+ )
125
+ findings.append(Finding("fail", "tools", message))
126
+ return findings or [Finding("ok", "tools", f"{len(manifests)} tool manifest(s), all pinned")]
127
+
128
+
129
+ def _content(spec: HarnessSpec) -> list[Finding]:
130
+ if spec.observability.capture_content:
131
+ return [
132
+ Finding(
133
+ "warn",
134
+ "content",
135
+ "observability.capture_content is on: post-redaction content goes into spans (R9)",
136
+ )
137
+ ]
138
+ return [Finding("ok", "content", "no prompt or output content in telemetry (R9)")]
139
+
140
+
141
+ def diagnose(path: Path, env: str | None, profile_dirs: list[Path]) -> list[Finding]:
142
+ """Everything ``doctor`` reports. The spec's directory becomes importable, as for the app."""
143
+ path = spec_path(path)
144
+ findings = [
145
+ Finding("warn", "plugins", problem) for problem in PluginCatalog.default().problems()
146
+ ]
147
+ try:
148
+ harness = Harness.load(path, env=env, profile_dirs=profile_dirs)
149
+ except HarnessError as exc:
150
+ return [*findings, Finding("fail", "load", exc.message)]
151
+ spec = harness.spec.spec
152
+ findings += _level(harness)
153
+ for task in harness.tasks:
154
+ runner = harness.runner_of(task)
155
+ findings.append(
156
+ Finding("ok", "hooks", f"task {task}: runner {runner.name} reaches every hook it needs")
157
+ )
158
+ findings += _owners(harness) + _pins(spec) + _tools(harness) + _shadow(spec) + _content(spec)
159
+ return findings
160
+
161
+
162
+ def doctor(
163
+ path: Annotated[
164
+ Path | None, typer.Argument(help="The app spec (default: harness.yaml).")
165
+ ] = None,
166
+ env: Annotated[
167
+ str | None, typer.Option("--env", "-e", help="Overlay: harness.<env>.yaml.")
168
+ ] = None,
169
+ profile_dir: Annotated[list[Path] | None, typer.Option("--profile-dir")] = None,
170
+ strict: Annotated[bool, typer.Option("--strict", help="Exit 1 on warnings too.")] = False,
171
+ output: Annotated[Literal["text", "json"], typer.Option("--format")] = "text",
172
+ ) -> None:
173
+ """Check a spec: adoption level, hooks, retry owners, pins, shadow guards, content."""
174
+ findings = diagnose(spec_path(path), env, profile_dir or [])
175
+ if output == "json":
176
+ typer.echo(json.dumps([asdict(f) for f in findings], indent=2))
177
+ else:
178
+ width = max(len(f.check) for f in findings)
179
+ for finding in findings:
180
+ typer.echo(
181
+ f"{finding.severity.upper():<4} {finding.check:<{width}} {finding.message}"
182
+ )
183
+ worst = {f.severity for f in findings}
184
+ if "fail" in worst or (strict and "warn" in worst):
185
+ raise typer.Exit(code=1)
@@ -0,0 +1,276 @@
1
+ """``harnessweaver eval run`` and ``eval report`` (DESIGNS No. 23).
2
+
3
+ ``eval run`` runs the spec's eval suites (``evals: {task: [files]}``) with the
4
+ runs kept in memory, compares each with its baseline in
5
+ ``.harness/baselines/<task>.<suite>.json`` and reports the guards in shadow mode.
6
+ It exits 1 when a suite misses its threshold or a case that passed in the
7
+ baseline fails, and 2 when the suites can't run. ``eval report`` summarizes the
8
+ production runs ``evals.online`` sampled.
9
+ """
10
+
11
+ import json
12
+ from collections import Counter
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Annotated, Any
16
+
17
+ import anyio
18
+ import typer
19
+ from pydantic import ValidationError
20
+
21
+ from harnessweaver import Harness
22
+ from harnessweaver.core.errors import HarnessError, SpecError
23
+ from harnessweaver.core.evals import (
24
+ Baseline,
25
+ Comparison,
26
+ EvalDataset,
27
+ OnlineSample,
28
+ ShadowGuardReport,
29
+ SuiteResult,
30
+ )
31
+ from harnessweaver.modules.evals import (
32
+ compare,
33
+ load_dataset,
34
+ online_shadow_report,
35
+ shadow_guards,
36
+ suite_shadow_report,
37
+ )
38
+ from harnessweaver.modules.state import MemoryStateStore
39
+ from harnessweaver_cli._load import fail, load_harness
40
+ from harnessweaver_cli.spec import Env, Format, Path_, ProfileDirs
41
+
42
+ __all__ = ["eval_app"]
43
+
44
+ eval_app = typer.Typer(help="Run eval suites and read online samples.", no_args_is_help=True)
45
+
46
+ CANT_RUN = 2
47
+ BASELINES = Path(".harness") / "baselines"
48
+
49
+ Names = Annotated[list[str] | None, typer.Option(help="Only these (repeatable).")]
50
+
51
+
52
+ @dataclass
53
+ class _Suite:
54
+ dataset: EvalDataset
55
+ result: SuiteResult
56
+ baseline: Path
57
+ comparison: Comparison
58
+ had_baseline: bool
59
+ shadow: list[ShadowGuardReport]
60
+ updated: bool = False
61
+
62
+ def ok(self, updating: bool) -> bool:
63
+ return self.result.passed and (updating or not self.comparison.regression)
64
+
65
+
66
+ def _base(harness: Harness) -> Path:
67
+ path = harness.spec.path
68
+ return path.parent if path is not None else Path.cwd()
69
+
70
+
71
+ def _datasets(harness: Harness, tasks: list[str], suites: list[str]) -> list[EvalDataset]:
72
+ found: list[EvalDataset] = []
73
+ for task, files in harness.spec.spec.evals.suites().items():
74
+ if tasks and task not in tasks:
75
+ continue
76
+ for file in files:
77
+ path = Path(file) if Path(file).is_absolute() else _base(harness) / file
78
+ dataset = load_dataset(path)
79
+ if dataset.task != task:
80
+ raise SpecError(f"evals.{task} lists {file}, whose task is {dataset.task}")
81
+ if not suites or dataset.suite in suites:
82
+ found.append(dataset)
83
+ if not found:
84
+ raise SpecError("no eval suites to run; list datasets under evals: {task: [files]}")
85
+ return found
86
+
87
+
88
+ def _read_baseline(path: Path) -> Baseline | None:
89
+ if not path.is_file():
90
+ return None
91
+ try:
92
+ return Baseline.model_validate_json(path.read_text(encoding="utf-8"))
93
+ except ValidationError:
94
+ raise SpecError(
95
+ f"{path} isn't a baseline; delete it or run with --update-baseline"
96
+ ) from None
97
+
98
+
99
+ async def _run_suites(
100
+ harness: Harness, datasets: list[EvalDataset], baselines: Path, update: bool
101
+ ) -> list[_Suite]:
102
+ done: list[_Suite] = []
103
+ for dataset in datasets:
104
+ result = await harness.evaluate(dataset)
105
+ path = baselines / f"{dataset.task}.{dataset.suite}.json"
106
+ baseline = _read_baseline(path)
107
+ suite = _Suite(
108
+ dataset=dataset,
109
+ result=result,
110
+ baseline=path,
111
+ comparison=compare(baseline, {c.id: c.passed for c in result.cases}),
112
+ had_baseline=baseline is not None,
113
+ shadow=suite_shadow_report(harness.spec.spec, dataset, result),
114
+ )
115
+ if update and result.passed:
116
+ written = Baseline(
117
+ suite=dataset.suite,
118
+ task=dataset.task,
119
+ pass_rate=result.pass_rate,
120
+ cases={c.id: c.passed for c in result.cases},
121
+ )
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ path.write_text(written.model_dump_json(indent=2) + "\n", encoding="utf-8")
124
+ suite.updated = True
125
+ done.append(suite)
126
+ return done
127
+
128
+
129
+ def _listed(ids: tuple[str, ...] | None) -> str:
130
+ return ", ".join(ids) if ids else "none"
131
+
132
+
133
+ def _print(suite: _Suite, updating: bool) -> None:
134
+ result = suite.result
135
+ passed = sum(1 for c in result.cases if c.passed)
136
+ typer.echo(
137
+ f"{'ok' if suite.ok(updating) else 'FAIL'}: {result.suite} ({result.task}): "
138
+ f"{passed}/{len(result.cases)} passed, pass rate {result.pass_rate} "
139
+ f"(threshold {result.threshold})"
140
+ )
141
+ for case in result.cases:
142
+ if not case.passed:
143
+ failed = "; ".join(
144
+ g.grader + (f" ({g.reason})" if g.reason else "")
145
+ for g in case.grades
146
+ if not g.passed
147
+ )
148
+ typer.echo(f" failed {case.id} [{case.status.value}]: {failed}")
149
+ found = suite.comparison
150
+ if found.regressed:
151
+ typer.echo(f" regressed since the baseline: {', '.join(found.regressed)}")
152
+ if found.fixed:
153
+ typer.echo(f" fixed since the baseline: {', '.join(found.fixed)}")
154
+ if not suite.had_baseline and not suite.updated:
155
+ typer.echo(" no baseline yet; --update-baseline writes one")
156
+ for guard in suite.shadow:
157
+ typer.echo(
158
+ f" shadow {guard.guard}: would fire on {guard.would_fire}/{guard.runs} "
159
+ f"({guard.rate}); false positives: {_listed(guard.false_positives)}; "
160
+ f"misses: {_listed(guard.misses)}"
161
+ )
162
+ if suite.updated:
163
+ typer.echo(f" baseline written to {suite.baseline}")
164
+
165
+
166
+ @eval_app.command("run")
167
+ def run(
168
+ path: Path_ = None,
169
+ env: Env = None,
170
+ profile_dir: ProfileDirs = None,
171
+ suite: Names = None,
172
+ task: Names = None,
173
+ baselines: Annotated[
174
+ Path | None,
175
+ typer.Option(help="Where baselines live (default: .harness/baselines next to the spec)."),
176
+ ] = None,
177
+ update_baseline: Annotated[
178
+ bool, typer.Option("--update-baseline", help="Accept these results as the baselines.")
179
+ ] = False,
180
+ output: Format = "text",
181
+ ) -> None:
182
+ """Run the eval suites, compare them with their baselines and report shadow guards.
183
+
184
+ Exits 1 when a suite misses its pass-rate threshold or a case that passed in
185
+ the baseline fails; 2 when the suites can't run.
186
+ """
187
+ harness = load_harness(path, env, profile_dir or [], state=MemoryStateStore(), code=CANT_RUN)
188
+ try:
189
+ datasets = _datasets(harness, task or [], suite or [])
190
+ where = baselines or _base(harness) / BASELINES
191
+ suites = anyio.run(_run_suites, harness, datasets, where, update_baseline)
192
+ except HarnessError as exc:
193
+ raise fail(exc, CANT_RUN) from None
194
+ if output == "json":
195
+ body = [
196
+ {
197
+ "suite": s.dataset.suite,
198
+ "task": s.dataset.task,
199
+ "ok": s.ok(update_baseline),
200
+ "result": s.result.model_dump(mode="json"),
201
+ "comparison": {**s.comparison.model_dump(), "regression": s.comparison.regression},
202
+ "baseline": str(s.baseline) if s.had_baseline or s.updated else None,
203
+ "baseline_updated": s.updated,
204
+ "shadow": [g.model_dump(mode="json") for g in s.shadow],
205
+ }
206
+ for s in suites
207
+ ]
208
+ typer.echo(json.dumps(body, indent=2))
209
+ else:
210
+ for s in suites:
211
+ _print(s, update_baseline)
212
+ if not all(s.ok(update_baseline) for s in suites):
213
+ raise typer.Exit(code=1)
214
+
215
+
216
+ def _summary(task: str | None, samples: list[OnlineSample], guards: list[str]) -> dict[str, Any]:
217
+ rates: dict[str, list[int]] = {}
218
+ for sample in samples:
219
+ for grade in sample.grades:
220
+ counts = rates.setdefault(grade.grader, [0, 0])
221
+ counts[0] += grade.passed
222
+ counts[1] += 1
223
+ return {
224
+ "task": task,
225
+ "samples": len(samples),
226
+ "statuses": dict(Counter(s.status.value for s in samples)),
227
+ "graders": {label: round(ok / of, 6) for label, (ok, of) in rates.items()},
228
+ "shadow": [g.model_dump(mode="json") for g in online_shadow_report(guards, samples)],
229
+ }
230
+
231
+
232
+ @eval_app.command("report")
233
+ def report(
234
+ path: Path_ = None,
235
+ env: Env = None,
236
+ profile_dir: ProfileDirs = None,
237
+ task: Annotated[str | None, typer.Option(help="Only this task.")] = None,
238
+ output: Format = "text",
239
+ ) -> None:
240
+ """Summarize the production runs evals.online sampled: statuses, grader pass rates and
241
+ how often each guard in shadow mode would have fired (R8)."""
242
+ harness = load_harness(path, env, profile_dir or [], code=CANT_RUN)
243
+
244
+ async def read() -> list[OnlineSample]:
245
+ try:
246
+ return await harness.samples(task)
247
+ finally:
248
+ await harness.aclose()
249
+
250
+ samples = anyio.run(read)
251
+ tasks = sorted({s.task or "" for s in samples})
252
+ spec = harness.spec.spec
253
+ body = [
254
+ _summary(
255
+ name or None,
256
+ [s for s in samples if (s.task or "") == name],
257
+ shadow_guards(spec, name or None),
258
+ )
259
+ for name in tasks
260
+ ]
261
+ if output == "json":
262
+ typer.echo(json.dumps(body, indent=2))
263
+ return
264
+ if not body:
265
+ typer.echo("no online samples yet; set evals.online.sample_rate to sample runs")
266
+ return
267
+ for summary in body:
268
+ statuses = ", ".join(f"{n} {status}" for status, n in summary["statuses"].items())
269
+ typer.echo(f"{summary['task'] or '(no task)'}: {summary['samples']} samples ({statuses})")
270
+ for label, rate in summary["graders"].items():
271
+ typer.echo(f" grader {label}: passed {rate}")
272
+ for guard in summary["shadow"]:
273
+ typer.echo(
274
+ f" shadow {guard['guard']}: would fire on {guard['would_fire']}/{guard['runs']} "
275
+ f"({guard['rate']})"
276
+ )