agentprobe-testing 0.5.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.
- agentprobe/__init__.py +104 -0
- agentprobe/agents/__init__.py +0 -0
- agentprobe/agents/base.py +32 -0
- agentprobe/agents/rule_based.py +336 -0
- agentprobe/agents/scripted.py +30 -0
- agentprobe/agents/target_agent.py +106 -0
- agentprobe/agreement.py +80 -0
- agentprobe/classifier.py +159 -0
- agentprobe/cli.py +684 -0
- agentprobe/diff.py +150 -0
- agentprobe/domain.py +121 -0
- agentprobe/domains/__init__.py +0 -0
- agentprobe/domains/access_control/__init__.py +0 -0
- agentprobe/domains/access_control/agent.py +90 -0
- agentprobe/domains/access_control/clean.py +154 -0
- agentprobe/domains/access_control/complex_agent.py +123 -0
- agentprobe/domains/access_control/decoy.py +124 -0
- agentprobe/domains/access_control/domain.py +35 -0
- agentprobe/domains/access_control/entities.py +43 -0
- agentprobe/domains/access_control/injector_prompt.py +196 -0
- agentprobe/domains/access_control/rule_based_agent.py +263 -0
- agentprobe/domains/access_control/scenarios.py +17 -0
- agentprobe/domains/access_control/split.py +96 -0
- agentprobe/domains/access_control/tools.py +235 -0
- agentprobe/domains/access_control/trap.py +100 -0
- agentprobe/feedback.py +121 -0
- agentprobe/generic_world.py +99 -0
- agentprobe/injection.py +475 -0
- agentprobe/injector.py +810 -0
- agentprobe/llm.py +123 -0
- agentprobe/playbook.py +211 -0
- agentprobe/quickstart.py +295 -0
- agentprobe/reachability.py +196 -0
- agentprobe/registry.py +313 -0
- agentprobe/report.py +666 -0
- agentprobe/runner.py +317 -0
- agentprobe/scenario.py +75 -0
- agentprobe/scenarios/__init__.py +0 -0
- agentprobe/scenarios/clean.py +194 -0
- agentprobe/scenarios/decoy.py +272 -0
- agentprobe/scenarios/registry.py +16 -0
- agentprobe/scenarios/split.py +203 -0
- agentprobe/scenarios/trap.py +215 -0
- agentprobe/termui.py +154 -0
- agentprobe/tools.py +275 -0
- agentprobe/trajectory.py +107 -0
- agentprobe/triage.py +153 -0
- agentprobe/validate_scenarios.py +489 -0
- agentprobe/world.py +189 -0
- agentprobe_testing-0.5.0.dist-info/METADATA +127 -0
- agentprobe_testing-0.5.0.dist-info/RECORD +55 -0
- agentprobe_testing-0.5.0.dist-info/WHEEL +5 -0
- agentprobe_testing-0.5.0.dist-info/entry_points.txt +4 -0
- agentprobe_testing-0.5.0.dist-info/licenses/LICENSE +109 -0
- agentprobe_testing-0.5.0.dist-info/top_level.txt +1 -0
agentprobe/report.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Aggregates robustness-mode runs into the report format from spec
|
|
2
|
+
section 9.
|
|
3
|
+
|
|
4
|
+
Reporting rules (repair sheet): report n alongside every percentage;
|
|
5
|
+
suppress a per-kind rate where n < 5 and print the raw count instead
|
|
6
|
+
("LATE_INFO 0/1 (0%)" reads like a finding and is one event); never pool
|
|
7
|
+
classifier agreement across runs with different injector/prompt versions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from agentprobe.classifier import ClassifiedResponse
|
|
18
|
+
from agentprobe.injection import AppliedInjection, InjectionKind
|
|
19
|
+
from agentprobe.termui import blue, bold, cyan, dim, green, pass_fail, purple, red, yellow
|
|
20
|
+
from agentprobe.trajectory import Trajectory
|
|
21
|
+
|
|
22
|
+
_MAX_REPR_LEN = 160
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _truncate(s: str, limit: int = _MAX_REPR_LEN) -> str:
|
|
26
|
+
return s if len(s) <= limit else s[: limit - 1] + "…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_REPORT_CSS = """
|
|
30
|
+
:root {
|
|
31
|
+
color-scheme: light dark;
|
|
32
|
+
--bg: #ffffff; --bg-alt: #f7f8fa; --bg-card: #ffffff; --border: #e3e5ea;
|
|
33
|
+
--text: #14161c; --text-dim: #666b7a;
|
|
34
|
+
--purple: #7c3aed; --blue: #2563eb; --green: #16a34a; --red: #dc2626; --amber: #b45309;
|
|
35
|
+
--purple-bg: rgba(124,58,237,.08); --blue-bg: rgba(37,99,235,.08);
|
|
36
|
+
--green-bg: rgba(22,163,74,.10); --red-bg: rgba(220,38,38,.10); --amber-bg: rgba(180,83,9,.10);
|
|
37
|
+
}
|
|
38
|
+
@media (prefers-color-scheme: dark) {
|
|
39
|
+
:root {
|
|
40
|
+
--bg: #0a0b0f; --bg-alt: #14161d; --bg-card: #14161d; --border: #262a35;
|
|
41
|
+
--text: #e7e9ee; --text-dim: #8b90a3;
|
|
42
|
+
--purple: #a78bfa; --blue: #60a5fa; --green: #4ade80; --red: #f87171; --amber: #fbbf24;
|
|
43
|
+
--purple-bg: rgba(167,139,250,.14); --blue-bg: rgba(96,165,250,.14);
|
|
44
|
+
--green-bg: rgba(74,222,128,.14); --red-bg: rgba(248,113,113,.14); --amber-bg: rgba(251,191,36,.14);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
* { box-sizing: border-box; }
|
|
48
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
49
|
+
background: var(--bg); color: var(--text); margin: 0; line-height: 1.5; }
|
|
50
|
+
.page { max-width: 900px; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
|
|
51
|
+
.title-row { display: flex; align-items: center; gap: 0.55rem; }
|
|
52
|
+
.health-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
53
|
+
.health-dot--pass { background: var(--green); box-shadow: 0 0 0 4px var(--green-bg); }
|
|
54
|
+
.health-dot--warn { background: var(--amber); box-shadow: 0 0 0 4px var(--amber-bg); }
|
|
55
|
+
.health-dot--fail { background: var(--red); box-shadow: 0 0 0 4px var(--red-bg); }
|
|
56
|
+
h1 { font-size: 1.5rem; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
|
57
|
+
.subtitle { color: var(--text-dim); margin: 0.35rem 0 1.75rem; font-size: 0.875rem; }
|
|
58
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(158px, 1fr)); gap: 0.75rem; margin-bottom: 2rem; }
|
|
59
|
+
.stat { border: 1px solid var(--border); border-radius: 10px; padding: 0.85rem 1rem;
|
|
60
|
+
background: var(--bg-card); border-top: 3px solid var(--border); }
|
|
61
|
+
.stat--blue { border-top-color: var(--blue); }
|
|
62
|
+
.stat--purple { border-top-color: var(--purple); }
|
|
63
|
+
.stat--amber { border-top-color: var(--amber); }
|
|
64
|
+
.stat--green { border-top-color: var(--green); }
|
|
65
|
+
.stat-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase;
|
|
66
|
+
letter-spacing: 0.04em; font-weight: 600; }
|
|
67
|
+
.stat-value { font-size: 1.55rem; font-weight: 700; margin-top: 0.15rem; letter-spacing: -0.01em; }
|
|
68
|
+
.stat-value .frac { font-size: 1rem; font-weight: 500; color: var(--text-dim); }
|
|
69
|
+
.stat-sub { font-size: 0.78rem; color: var(--text-dim); margin-top: 0.2rem; }
|
|
70
|
+
.stat-bar { height: 5px; border-radius: 3px; background: var(--border); margin-top: 0.6rem; overflow: hidden; }
|
|
71
|
+
.stat-bar--warn { background: var(--amber-bg); }
|
|
72
|
+
.stat-bar--fail { background: var(--red-bg); }
|
|
73
|
+
.stat-bar-fill { height: 100%; border-radius: 3px; }
|
|
74
|
+
.stat-bar-fill--pass { background: var(--green); }
|
|
75
|
+
.stat-bar-fill--warn { background: var(--amber); }
|
|
76
|
+
.stat-bar-fill--fail { background: var(--red); }
|
|
77
|
+
.muted { color: var(--text-dim); font-size: 0.85rem; }
|
|
78
|
+
.empty-state { padding: 0.9rem 0; }
|
|
79
|
+
section { margin-bottom: 2rem; }
|
|
80
|
+
h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim);
|
|
81
|
+
font-weight: 700; display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
|
|
82
|
+
h2::before { content: ""; width: 3px; height: 0.85rem; border-radius: 2px; background: var(--purple); }
|
|
83
|
+
.card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--bg-card); }
|
|
84
|
+
table { border-collapse: collapse; width: 100%; }
|
|
85
|
+
.card table { margin: 0; }
|
|
86
|
+
th, td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); }
|
|
87
|
+
tr:last-child td { border-bottom: none; }
|
|
88
|
+
th { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.03em; font-weight: 600; }
|
|
89
|
+
td.num { font-variant-numeric: tabular-nums; }
|
|
90
|
+
.kind-tag { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82em;
|
|
91
|
+
background: var(--purple-bg); color: var(--purple); padding: 0.1em 0.45em; border-radius: 4px; }
|
|
92
|
+
.bar-track { position: relative; height: 6px; background: var(--border); border-radius: 3px; margin-top: 5px; }
|
|
93
|
+
.bar-ci { position: absolute; top: 0; height: 100%; background: var(--blue-bg); border-radius: 3px; }
|
|
94
|
+
.bar-point { position: absolute; top: -3px; width: 2px; height: 12px; background: var(--blue); border-radius: 1px; }
|
|
95
|
+
details { border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 8px;
|
|
96
|
+
margin-bottom: 0.55rem; background: var(--bg-card); overflow: hidden; }
|
|
97
|
+
details:has(.pill--fail) { border-left-color: var(--red); }
|
|
98
|
+
details:has(.pill--warn):not(:has(.pill--fail)) { border-left-color: var(--amber); }
|
|
99
|
+
details:has(.pill--pass):not(:has(.pill--fail)):not(:has(.pill--warn)) { border-left-color: var(--green); }
|
|
100
|
+
summary { cursor: pointer; font-weight: 600; padding: 0.65rem 0.9rem; display: flex; align-items: center;
|
|
101
|
+
justify-content: space-between; gap: 0.75rem; list-style: none; }
|
|
102
|
+
summary::-webkit-details-marker { display: none; }
|
|
103
|
+
summary::before { content: "\\25B8"; color: var(--purple); font-size: 0.95em; margin-right: 0.6rem; }
|
|
104
|
+
details[open] > summary::before { content: "\\25BE"; }
|
|
105
|
+
.tag-group { display: flex; gap: 0.35rem; flex-wrap: wrap; flex-shrink: 0; }
|
|
106
|
+
.pill { font-size: 0.68rem; font-weight: 700; padding: 0.16rem 0.5rem; border-radius: 999px;
|
|
107
|
+
text-transform: uppercase; letter-spacing: 0.02em; white-space: nowrap; }
|
|
108
|
+
.pill--pass { color: var(--green); background: var(--green-bg); }
|
|
109
|
+
.pill--fail { color: var(--red); background: var(--red-bg); }
|
|
110
|
+
.pill--warn { color: var(--amber); background: var(--amber-bg); }
|
|
111
|
+
.scenario-body { padding: 0 0.9rem 0.9rem; border-top: 1px solid var(--border); }
|
|
112
|
+
.run-label { font-size: 0.7rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em;
|
|
113
|
+
font-weight: 700; margin: 0.9rem 0 0.4rem; }
|
|
114
|
+
.step-table { font-size: 0.83rem; }
|
|
115
|
+
.step-table td, .step-table th { padding: 0.3rem 0.55rem; }
|
|
116
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em;
|
|
117
|
+
background: var(--bg-alt); padding: 0.1em 0.3em; border-radius: 4px; }
|
|
118
|
+
.pass { color: var(--green); font-weight: 600; }
|
|
119
|
+
.fail { color: var(--red); font-weight: 600; }
|
|
120
|
+
.warn { color: var(--amber); font-weight: 600; }
|
|
121
|
+
.injection-note { font-size: 0.83rem; margin-top: 0.5rem; padding: 0.55rem 0.7rem; border-radius: 6px;
|
|
122
|
+
border-left: 3px solid var(--amber); background: var(--amber-bg); }
|
|
123
|
+
.injection-note--invalid { border-left-color: var(--red); background: var(--red-bg); }
|
|
124
|
+
.injection-note--expired { border-left-color: var(--border); background: var(--bg-alt); }
|
|
125
|
+
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid var(--border);
|
|
126
|
+
font-size: 0.78rem; color: var(--text-dim); }
|
|
127
|
+
@media print {
|
|
128
|
+
.page { max-width: none; }
|
|
129
|
+
details { break-inside: avoid; }
|
|
130
|
+
summary::before { display: none; }
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_args(args: dict) -> str:
|
|
136
|
+
return ", ".join(f"{k}={v!r}" for k, v in args.items())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _notable_steps(steps: list) -> list:
|
|
140
|
+
"""The steps worth showing in a human-facing report: irreversible
|
|
141
|
+
actions and anything that went wrong. A successful read (get_ticket,
|
|
142
|
+
get_order, ...) is the agent looking something up -- noise in a report
|
|
143
|
+
meant to answer "what did it actually do," not a trace of its
|
|
144
|
+
reasoning. Commits, errors, and injected failures are the actual
|
|
145
|
+
story."""
|
|
146
|
+
return [s for s in steps if s.is_commit or not s.ok or s.injected_error]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _format_step_line(step) -> str:
|
|
150
|
+
call = f"{step.tool_name}({_format_args(step.tool_args)})"
|
|
151
|
+
tag = " [COMMIT]" if step.is_commit else ""
|
|
152
|
+
if step.injected_error:
|
|
153
|
+
status = red("injected error")
|
|
154
|
+
detail = f" {dim(_truncate(str(step.result)))}"
|
|
155
|
+
elif step.ok:
|
|
156
|
+
status = green("ok")
|
|
157
|
+
detail = ""
|
|
158
|
+
else:
|
|
159
|
+
status = red("error")
|
|
160
|
+
detail = f" {dim(_truncate(str(step.result)))}"
|
|
161
|
+
return f" step {step.index:<2d} {call}{tag} {status}{detail}"
|
|
162
|
+
|
|
163
|
+
_MIN_N_FOR_RATE = 5
|
|
164
|
+
_Z_95 = 1.96
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def wilson_interval(successes: int, n: int, z: float = _Z_95) -> tuple[float, float]:
|
|
168
|
+
"""95% Wilson score interval for a binomial proportion, as (lower,
|
|
169
|
+
upper) fractions in [0, 1]. Used instead of the naive
|
|
170
|
+
successes/n +/- z*sqrt(p(1-p)/n) (Wald) interval, which badly
|
|
171
|
+
misbehaves near 0%/100% and at the small n (5-20) this harness's
|
|
172
|
+
per-kind handled-rates typically have -- exactly where an honest
|
|
173
|
+
interval matters most. A bare percentage at n=5 ("80%") reads as far
|
|
174
|
+
more precise than it is; reporting it without a range overstates
|
|
175
|
+
confidence a company would reasonably act on.
|
|
176
|
+
"""
|
|
177
|
+
if n == 0:
|
|
178
|
+
return (0.0, 0.0)
|
|
179
|
+
phat = successes / n
|
|
180
|
+
z2 = z * z
|
|
181
|
+
denom = 1 + z2 / n
|
|
182
|
+
center = phat + z2 / (2 * n)
|
|
183
|
+
margin = z * math.sqrt(phat * (1 - phat) / n + z2 / (4 * n * n))
|
|
184
|
+
lower = (center - margin) / denom
|
|
185
|
+
upper = (center + margin) / denom
|
|
186
|
+
return (max(0.0, lower), min(1.0, upper))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class InjectionRecord:
|
|
191
|
+
scenario_id: str
|
|
192
|
+
applied: AppliedInjection
|
|
193
|
+
classification: Optional[ClassifiedResponse] = None
|
|
194
|
+
triggered: bool = True
|
|
195
|
+
"""False when a fired injection's effect was never observable by the
|
|
196
|
+
Target (zero tool calls after it fired) -- there's no response to
|
|
197
|
+
classify, so these are excluded from classification stats rather than
|
|
198
|
+
fed to the classifier to confabulate a verdict for. Always True for
|
|
199
|
+
TOOL_ERROR now: firing IS interception, by construction (see
|
|
200
|
+
injection_was_triggered)."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class Report:
|
|
205
|
+
mode: str
|
|
206
|
+
injector_model: str
|
|
207
|
+
target_model: str
|
|
208
|
+
clean_trajectories: list[Trajectory]
|
|
209
|
+
chaos_trajectories: list[Trajectory]
|
|
210
|
+
injection_records: list[InjectionRecord] = field(default_factory=list)
|
|
211
|
+
classifier_agreement: Optional[tuple[int, int]] = None
|
|
212
|
+
"""(agreed, total) against hand labels, spec section 7's mandated
|
|
213
|
+
check. Only meaningful for THIS injector/classifier configuration --
|
|
214
|
+
never pool across runs with different injector or prompt versions."""
|
|
215
|
+
injector_cost_usd: float = 0.0
|
|
216
|
+
"""Sum of every ModelInjector instance's total_cost_usd across the
|
|
217
|
+
run -- the caller (cli.py) has to supply this since a Report only
|
|
218
|
+
holds Trajectories/InjectionRecords, not the Injector instances
|
|
219
|
+
themselves. 0.0 (and omitted from output) for none/hardcoded/replay
|
|
220
|
+
injectors, which never call a model at all."""
|
|
221
|
+
|
|
222
|
+
def _compute(self) -> dict:
|
|
223
|
+
"""Shared computation behind both render() and render_html() --
|
|
224
|
+
kept in one place so the two views can't silently drift apart on
|
|
225
|
+
what a number actually means."""
|
|
226
|
+
n = len(self.chaos_trajectories)
|
|
227
|
+
chaos_passed = sum(1 for t in self.chaos_trajectories if t.passed)
|
|
228
|
+
clean_passed = sum(1 for t in self.clean_trajectories if t.passed) if self.clean_trajectories else None
|
|
229
|
+
|
|
230
|
+
total = len(self.injection_records)
|
|
231
|
+
invalid = sum(1 for r in self.injection_records if not r.applied.valid)
|
|
232
|
+
valid = total - invalid
|
|
233
|
+
not_observed = sum(1 for r in self.injection_records if r.applied.valid and not r.triggered)
|
|
234
|
+
expired_total = sum(len(t.expired_injections) for t in self.chaos_trajectories)
|
|
235
|
+
|
|
236
|
+
valid_records = [r for r in self.injection_records if r.applied.valid and r.triggered]
|
|
237
|
+
classified = [r for r in valid_records if r.classification is not None]
|
|
238
|
+
classification_counts = Counter(r.classification.classification.value for r in classified)
|
|
239
|
+
|
|
240
|
+
target_cost_usd = sum(t.total_cost_usd for t in self.clean_trajectories) + sum(
|
|
241
|
+
t.total_cost_usd for t in self.chaos_trajectories
|
|
242
|
+
)
|
|
243
|
+
classifier_cost_usd = sum(r.classification.cost_usd for r in classified)
|
|
244
|
+
|
|
245
|
+
per_kind = []
|
|
246
|
+
for kind in InjectionKind:
|
|
247
|
+
kind_records = [r for r in classified if r.applied.injection.kind == kind]
|
|
248
|
+
if not kind_records:
|
|
249
|
+
continue
|
|
250
|
+
handled = sum(1 for r in kind_records if r.classification.classification.value == "HANDLED")
|
|
251
|
+
k = len(kind_records)
|
|
252
|
+
interval = wilson_interval(handled, k) if k >= _MIN_N_FOR_RATE else None
|
|
253
|
+
per_kind.append({"kind": kind.value, "handled": handled, "n": k, "interval": interval})
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
"n": n,
|
|
257
|
+
"chaos_passed": chaos_passed,
|
|
258
|
+
"clean_passed": clean_passed,
|
|
259
|
+
"clean_total": len(self.clean_trajectories),
|
|
260
|
+
"total": total,
|
|
261
|
+
"invalid": invalid,
|
|
262
|
+
"valid": valid,
|
|
263
|
+
"not_observed": not_observed,
|
|
264
|
+
"expired_total": expired_total,
|
|
265
|
+
"classified_n": len(classified),
|
|
266
|
+
"classification_counts": classification_counts,
|
|
267
|
+
"per_kind": per_kind,
|
|
268
|
+
"target_cost_usd": target_cost_usd,
|
|
269
|
+
"classifier_cost_usd": classifier_cost_usd,
|
|
270
|
+
"injector_cost_usd": self.injector_cost_usd,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def render(self) -> str:
|
|
274
|
+
c = self._compute()
|
|
275
|
+
lines = [
|
|
276
|
+
blue(
|
|
277
|
+
f"scenarios: {c['n']} mode: {self.mode} injector: {self.injector_model} "
|
|
278
|
+
f"target: {self.target_model}"
|
|
279
|
+
),
|
|
280
|
+
]
|
|
281
|
+
if c["clean_passed"] is not None:
|
|
282
|
+
lines.append(f"{purple('clean pass rate:')} {c['clean_passed']:2d}/{c['clean_total']}")
|
|
283
|
+
else:
|
|
284
|
+
lines.append(f"{purple('clean pass rate:')} n/a (no control run in recovery mode -- see spec section 12)")
|
|
285
|
+
lines.append(f"{purple('chaos pass rate:')} {c['chaos_passed']:2d}/{c['n']}")
|
|
286
|
+
if c["target_cost_usd"] > 0:
|
|
287
|
+
cost = cyan(f"${c['target_cost_usd']:.4f}")
|
|
288
|
+
lines.append(f"{dim('target API cost:')} {cost}")
|
|
289
|
+
if c["injector_cost_usd"] > 0:
|
|
290
|
+
cost = cyan(f"${c['injector_cost_usd']:.4f}")
|
|
291
|
+
lines.append(f"{dim('injector API cost:')} {cost}")
|
|
292
|
+
if c["classifier_cost_usd"] > 0:
|
|
293
|
+
cost = cyan(f"${c['classifier_cost_usd']:.4f}")
|
|
294
|
+
lines.append(f"{dim('classifier API cost:')} {cost}")
|
|
295
|
+
total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"]
|
|
296
|
+
if total_cost > 0:
|
|
297
|
+
cost = cyan(f"${total_cost:.4f}")
|
|
298
|
+
lines.append(f"{dim('total API cost:')} {cost}")
|
|
299
|
+
lines.append("")
|
|
300
|
+
|
|
301
|
+
invalid_pct = f"{100 * c['invalid'] / c['total']:.0f}%" if c["total"] else "0%"
|
|
302
|
+
lines.append(
|
|
303
|
+
f"injections fired: {c['total']} invalid: {c['invalid']} ({invalid_pct}, n={c['total']}) "
|
|
304
|
+
f"valid: {c['valid']}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if c["not_observed"]:
|
|
308
|
+
lines.append(
|
|
309
|
+
f" of which unobservable: {c['not_observed']} (fired, but zero Target actions "
|
|
310
|
+
"afterward -- excluded below, not classified)"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if c["expired_total"]:
|
|
314
|
+
lines.append(
|
|
315
|
+
yellow(
|
|
316
|
+
f"expired (armed, trigger never matched): {c['expired_total']} -- a bad prediction "
|
|
317
|
+
"by the Injector, not an unfair one (that's invalid)"
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
lines.append("")
|
|
321
|
+
|
|
322
|
+
if c["classified_n"]:
|
|
323
|
+
lines.append(bold(purple(f"response classification (valid injections, n={c['classified_n']}):")))
|
|
324
|
+
for label in ["HANDLED", "IGNORED", "MISREAD", "OVERREACTED", "LOOPED"]:
|
|
325
|
+
if c["classification_counts"].get(label):
|
|
326
|
+
lines.append(f" {label:<12} {c['classification_counts'][label]}")
|
|
327
|
+
lines.append("")
|
|
328
|
+
|
|
329
|
+
lines.append(bold(purple("by injection kind (handled rate):")))
|
|
330
|
+
for row in c["per_kind"]:
|
|
331
|
+
kind_label = blue(f"{row['kind']:<15}")
|
|
332
|
+
if row["interval"] is None:
|
|
333
|
+
lines.append(f" {kind_label} {row['handled']}/{row['n']} (n={row['n']}, too few to rate)")
|
|
334
|
+
else:
|
|
335
|
+
lo, hi = row["interval"]
|
|
336
|
+
pct = f"{100 * row['handled'] / row['n']:.0f}%"
|
|
337
|
+
lines.append(
|
|
338
|
+
f" {kind_label} {row['handled']}/{row['n']} "
|
|
339
|
+
f"({pct}, 95% CI {100*lo:.0f}-{100*hi:.0f}%, n={row['n']})"
|
|
340
|
+
)
|
|
341
|
+
lines.append("")
|
|
342
|
+
|
|
343
|
+
if self.classifier_agreement is not None:
|
|
344
|
+
agreed, checked = self.classifier_agreement
|
|
345
|
+
lines.append(f"classifier agreement with human labels (this configuration only): {agreed}/{checked}")
|
|
346
|
+
|
|
347
|
+
return "\n".join(lines).rstrip()
|
|
348
|
+
|
|
349
|
+
def render_scenario_detail(self) -> str:
|
|
350
|
+
"""Scenario-by-scenario, step-by-step breakdown -- every tool call
|
|
351
|
+
the Target made in both the clean and chaos run, in order, plus
|
|
352
|
+
exactly where an injection fired and what it did. render()'s
|
|
353
|
+
aggregate numbers answer "how did this configuration do overall";
|
|
354
|
+
this answers "what actually happened in scenario X, step by step"
|
|
355
|
+
-- the thing you actually want to read when a specific scenario
|
|
356
|
+
failed and you need to know why, not just that it did.
|
|
357
|
+
"""
|
|
358
|
+
clean_by_id = {t.scenario_id: t for t in self.clean_trajectories}
|
|
359
|
+
chaos_by_id = {t.scenario_id: t for t in self.chaos_trajectories}
|
|
360
|
+
scenario_ids = list(dict.fromkeys([*clean_by_id, *chaos_by_id])) # stable order, no dupes
|
|
361
|
+
|
|
362
|
+
lines = [bold(purple("scenario detail")), ""]
|
|
363
|
+
for scenario_id in scenario_ids:
|
|
364
|
+
clean = clean_by_id.get(scenario_id)
|
|
365
|
+
chaos = chaos_by_id.get(scenario_id)
|
|
366
|
+
header = blue(f"=== {scenario_id} ===")
|
|
367
|
+
if clean is not None:
|
|
368
|
+
header += f" clean: {pass_fail(clean.passed)}"
|
|
369
|
+
if chaos is not None:
|
|
370
|
+
header += f" chaos: {pass_fail(chaos.passed)}"
|
|
371
|
+
if chaos.discarded:
|
|
372
|
+
header += f" {yellow('[DISCARDED: invalid injection]')}"
|
|
373
|
+
lines.append(bold(header))
|
|
374
|
+
|
|
375
|
+
if clean is not None:
|
|
376
|
+
lines.append(f" {dim('-- clean run --')}")
|
|
377
|
+
notable = _notable_steps(clean.steps)
|
|
378
|
+
for step in notable:
|
|
379
|
+
lines.append(_format_step_line(step))
|
|
380
|
+
skipped = len(clean.steps) - len(notable)
|
|
381
|
+
if skipped:
|
|
382
|
+
plural = "s" if skipped != 1 else ""
|
|
383
|
+
lines.append(f" {dim(f'({skipped} read-only call{plural} omitted)')}")
|
|
384
|
+
lines.append(f" final answer: {_truncate(str(clean.final_answer))}")
|
|
385
|
+
lines.append("")
|
|
386
|
+
|
|
387
|
+
if chaos is not None:
|
|
388
|
+
lines.append(f" {dim('-- chaos run --')}")
|
|
389
|
+
notable = _notable_steps(chaos.steps)
|
|
390
|
+
for step in notable:
|
|
391
|
+
lines.append(_format_step_line(step))
|
|
392
|
+
skipped = len(chaos.steps) - len(notable)
|
|
393
|
+
if skipped:
|
|
394
|
+
plural = "s" if skipped != 1 else ""
|
|
395
|
+
lines.append(f" {dim(f'({skipped} read-only call{plural} omitted)')}")
|
|
396
|
+
lines.append(f" final answer: {_truncate(str(chaos.final_answer))}")
|
|
397
|
+
if chaos.injections:
|
|
398
|
+
for a in chaos.injections:
|
|
399
|
+
validity = "" if a.valid else f" {red('INVALID')}"
|
|
400
|
+
lines.append(
|
|
401
|
+
f" {purple('->')} injection fired: {purple(a.injection.kind.value)} @ step {a.fired_at_step} "
|
|
402
|
+
f"(trigger: {a.trigger_kind}){validity} -- {a.injection.intent}"
|
|
403
|
+
)
|
|
404
|
+
if chaos.expired_injections:
|
|
405
|
+
for e in chaos.expired_injections:
|
|
406
|
+
lines.append(
|
|
407
|
+
f" {dim('->')} injection armed but never fired: {e.injection.kind.value} "
|
|
408
|
+
f"(trigger: {e.trigger.describe()})"
|
|
409
|
+
)
|
|
410
|
+
lines.append("")
|
|
411
|
+
|
|
412
|
+
return "\n".join(lines).rstrip()
|
|
413
|
+
|
|
414
|
+
def render_html(self) -> str:
|
|
415
|
+
"""A self-contained HTML report -- no external CSS/JS/fonts --
|
|
416
|
+
for handing to a stakeholder who isn't going to read a terminal
|
|
417
|
+
dump. Same numbers as render(), same _compute(), just a different
|
|
418
|
+
presentation.
|
|
419
|
+
|
|
420
|
+
No <script> anywhere, by design (see test_render_html_is_well_
|
|
421
|
+
formed_and_self_contained) -- every bit of interactivity here
|
|
422
|
+
(collapsible scenarios, status-colored accents) is native <details>
|
|
423
|
+
plus CSS :has(), not JS. That also means <details> and <summary>
|
|
424
|
+
must stay exactly as bare/plain as they were before this method
|
|
425
|
+
grew styling (see test_render_html_includes_a_collapsible_
|
|
426
|
+
scenario_detail_section, which matches the literal substrings
|
|
427
|
+
"<details>" and "<summary>a") -- status coloring is applied via
|
|
428
|
+
:has(.pill--fail) etc. on a *wrapper* class instead of attributes
|
|
429
|
+
on those two tags.
|
|
430
|
+
"""
|
|
431
|
+
import html as _html
|
|
432
|
+
|
|
433
|
+
c = self._compute()
|
|
434
|
+
|
|
435
|
+
def esc(s: object) -> str:
|
|
436
|
+
return _html.escape(str(s))
|
|
437
|
+
|
|
438
|
+
def rate_tier(pct: float) -> str:
|
|
439
|
+
if pct >= 80:
|
|
440
|
+
return "pass"
|
|
441
|
+
if pct >= 40:
|
|
442
|
+
return "warn"
|
|
443
|
+
return "fail"
|
|
444
|
+
|
|
445
|
+
def stat_card(label: str, value_html: str, sub_html: str = "", accent: str = "blue", bar_pct=None) -> str:
|
|
446
|
+
bar = ""
|
|
447
|
+
if bar_pct is not None:
|
|
448
|
+
tier = rate_tier(bar_pct)
|
|
449
|
+
bar = (
|
|
450
|
+
f"<div class='stat-bar stat-bar--{tier}'><div class='stat-bar-fill stat-bar-fill--{tier}' "
|
|
451
|
+
f"style='width:{bar_pct:.0f}%'></div></div>"
|
|
452
|
+
)
|
|
453
|
+
sub = f"<div class='stat-sub'>{sub_html}</div>" if sub_html else ""
|
|
454
|
+
return (
|
|
455
|
+
f"<div class='stat stat--{accent}'><div class='stat-label'>{esc(label)}</div>"
|
|
456
|
+
f"<div class='stat-value'>{value_html}</div>{sub}{bar}</div>"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
stats_html = []
|
|
460
|
+
if c["clean_passed"] is not None:
|
|
461
|
+
clean_pct = 100 * c["clean_passed"] / c["clean_total"] if c["clean_total"] else 0
|
|
462
|
+
stats_html.append(
|
|
463
|
+
stat_card(
|
|
464
|
+
"Clean pass rate",
|
|
465
|
+
f"{c['clean_passed']}<span class='frac'>/{c['clean_total']}</span>",
|
|
466
|
+
accent="blue", bar_pct=clean_pct,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
stats_html.append(stat_card("Clean pass rate", "n/a (recovery mode)", accent="blue"))
|
|
471
|
+
|
|
472
|
+
chaos_pct = 100 * c["chaos_passed"] / c["n"] if c["n"] else 0
|
|
473
|
+
stats_html.append(
|
|
474
|
+
stat_card(
|
|
475
|
+
"Chaos pass rate",
|
|
476
|
+
f"{c['chaos_passed']}<span class='frac'>/{c['n']}</span>",
|
|
477
|
+
f"{chaos_pct:.0f}%" if c["n"] else "n/a",
|
|
478
|
+
accent="blue", bar_pct=chaos_pct if c["n"] else None,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
invalid_pct = 100 * c["invalid"] / c["total"] if c["total"] else 0
|
|
483
|
+
stats_html.append(
|
|
484
|
+
stat_card(
|
|
485
|
+
"Injections fired", str(c["total"]),
|
|
486
|
+
f"invalid: {c['invalid']} ({invalid_pct:.0f}%) · valid: {c['valid']}",
|
|
487
|
+
accent="purple",
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
stats_html.append(stat_card("Expired (never fired)", str(c["expired_total"]), accent="amber"))
|
|
491
|
+
|
|
492
|
+
if c["target_cost_usd"] > 0:
|
|
493
|
+
stats_html.append(stat_card("Target API cost", f"${c['target_cost_usd']:.4f}", accent="green"))
|
|
494
|
+
if c["injector_cost_usd"] > 0:
|
|
495
|
+
stats_html.append(stat_card("Injector API cost", f"${c['injector_cost_usd']:.4f}", accent="green"))
|
|
496
|
+
if c["classifier_cost_usd"] > 0:
|
|
497
|
+
stats_html.append(stat_card("Classifier API cost", f"${c['classifier_cost_usd']:.4f}", accent="green"))
|
|
498
|
+
total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"]
|
|
499
|
+
if total_cost > 0:
|
|
500
|
+
stats_html.append(stat_card("Total API cost", f"${total_cost:.4f}", accent="green"))
|
|
501
|
+
|
|
502
|
+
if self.classifier_agreement is not None:
|
|
503
|
+
agreed, checked = self.classifier_agreement
|
|
504
|
+
stats_html.append(
|
|
505
|
+
stat_card("Classifier agreement", f"{agreed}/{checked}", "this configuration only", accent="blue")
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
health = rate_tier(chaos_pct) if c["n"] else "warn"
|
|
509
|
+
|
|
510
|
+
classification_rows = "".join(
|
|
511
|
+
f"<tr><td>{esc(label)}</td><td class='num'>{c['classification_counts'][label]}</td></tr>"
|
|
512
|
+
for label in ["HANDLED", "IGNORED", "MISREAD", "OVERREACTED", "LOOPED"]
|
|
513
|
+
if c["classification_counts"].get(label)
|
|
514
|
+
)
|
|
515
|
+
classification_html = (
|
|
516
|
+
f"<div class='card'><table><tbody>{classification_rows}</tbody></table></div>"
|
|
517
|
+
if classification_rows
|
|
518
|
+
else "<p class='muted empty-state'>No classified responses yet.</p>"
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
kind_rows = []
|
|
522
|
+
for row in c["per_kind"]:
|
|
523
|
+
if row["interval"] is None:
|
|
524
|
+
kind_rows.append(
|
|
525
|
+
f"<tr><td><span class='kind-tag'>{esc(row['kind'])}</span></td>"
|
|
526
|
+
f"<td class='num'>{row['handled']}/{row['n']}</td>"
|
|
527
|
+
f"<td class='muted'>n={row['n']}, too few to rate</td></tr>"
|
|
528
|
+
)
|
|
529
|
+
else:
|
|
530
|
+
lo, hi = row["interval"]
|
|
531
|
+
pct = 100 * row["handled"] / row["n"]
|
|
532
|
+
kind_rows.append(
|
|
533
|
+
f"<tr><td><span class='kind-tag'>{esc(row['kind'])}</span></td>"
|
|
534
|
+
f"<td class='num'>{row['handled']}/{row['n']}</td>"
|
|
535
|
+
f"<td>{pct:.0f}% <span class='muted'>(95% CI {100*lo:.0f}-{100*hi:.0f}%, n={row['n']})</span>"
|
|
536
|
+
f"<div class='bar-track'><div class='bar-ci' style='left:{100*lo:.1f}%;width:{100*(hi-lo):.1f}%'></div>"
|
|
537
|
+
f"<div class='bar-point' style='left:{pct:.1f}%'></div></div></td></tr>"
|
|
538
|
+
)
|
|
539
|
+
kind_table = (
|
|
540
|
+
f"<div class='card'><table><thead><tr><th>Injection kind</th><th>Handled</th><th>Rate</th></tr></thead>"
|
|
541
|
+
f"<tbody>{''.join(kind_rows)}</tbody></table></div>"
|
|
542
|
+
if kind_rows
|
|
543
|
+
else "<p class='muted empty-state'>No classified injections yet.</p>"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
scenario_detail_html = self._scenario_detail_html(esc)
|
|
547
|
+
|
|
548
|
+
return f"""<!doctype html>
|
|
549
|
+
<html><head><meta charset="utf-8"><title>AgentProbe report</title>
|
|
550
|
+
<style>
|
|
551
|
+
{_REPORT_CSS}
|
|
552
|
+
</style></head>
|
|
553
|
+
<body>
|
|
554
|
+
<div class="page">
|
|
555
|
+
<header>
|
|
556
|
+
<div class="title-row"><span class="health-dot health-dot--{health}"></span><h1>AgentProbe report</h1></div>
|
|
557
|
+
<div class="subtitle">mode: {esc(self.mode)} · injector: {esc(self.injector_model)} · target: {esc(self.target_model)} · {esc(c['n'])} scenarios</div>
|
|
558
|
+
</header>
|
|
559
|
+
<div class="stats">
|
|
560
|
+
{"".join(stats_html)}
|
|
561
|
+
</div>
|
|
562
|
+
<section>
|
|
563
|
+
<h2>Response classification (n={c['classified_n']})</h2>
|
|
564
|
+
{classification_html}
|
|
565
|
+
</section>
|
|
566
|
+
<section>
|
|
567
|
+
<h2>By injection kind — handled rate</h2>
|
|
568
|
+
{kind_table}
|
|
569
|
+
</section>
|
|
570
|
+
<section>
|
|
571
|
+
<h2>Scenario detail — step by step</h2>
|
|
572
|
+
{scenario_detail_html}
|
|
573
|
+
</section>
|
|
574
|
+
<footer>Generated by AgentProbe — adaptive chaos-testing for LLM agents.</footer>
|
|
575
|
+
</div>
|
|
576
|
+
</body></html>"""
|
|
577
|
+
|
|
578
|
+
def _scenario_detail_html(self, esc) -> str:
|
|
579
|
+
"""One collapsible <details> block per scenario, each with a full
|
|
580
|
+
step-by-step table for the clean and chaos runs -- the HTML
|
|
581
|
+
counterpart to render_scenario_detail()."""
|
|
582
|
+
|
|
583
|
+
def step_rows(steps) -> str:
|
|
584
|
+
rows = []
|
|
585
|
+
for s in steps:
|
|
586
|
+
if s.injected_error:
|
|
587
|
+
status = "<span class='fail'>injected error</span>"
|
|
588
|
+
elif s.ok:
|
|
589
|
+
status = "<span class='pass'>ok</span>"
|
|
590
|
+
else:
|
|
591
|
+
status = "<span class='fail'>error</span>"
|
|
592
|
+
tag = " <span class='muted'>[commit]</span>" if s.is_commit else ""
|
|
593
|
+
args = ", ".join(f"{k}={v!r}" for k, v in s.tool_args.items())
|
|
594
|
+
# only show the result payload for something that went
|
|
595
|
+
# wrong -- a successful call's full JSON dump is noise, not
|
|
596
|
+
# signal, in a report meant to show what happened
|
|
597
|
+
result = "" if s.ok and not s.injected_error else esc(_truncate(str(s.result), 200))
|
|
598
|
+
rows.append(
|
|
599
|
+
f"<tr><td class='num'>{s.index}</td>"
|
|
600
|
+
f"<td><code>{esc(s.tool_name)}({esc(args)})</code>{tag}</td>"
|
|
601
|
+
f"<td>{status}</td><td class='muted'>{result}</td></tr>"
|
|
602
|
+
)
|
|
603
|
+
return "".join(rows)
|
|
604
|
+
|
|
605
|
+
def run_section(label: str, traj) -> str:
|
|
606
|
+
if traj is None:
|
|
607
|
+
return ""
|
|
608
|
+
notable = _notable_steps(traj.steps)
|
|
609
|
+
skipped = len(traj.steps) - len(notable)
|
|
610
|
+
skipped_note = (
|
|
611
|
+
f"<p class='muted'>({skipped} read-only call{'s' if skipped != 1 else ''} omitted)</p>"
|
|
612
|
+
if skipped
|
|
613
|
+
else ""
|
|
614
|
+
)
|
|
615
|
+
table = (
|
|
616
|
+
"<table class='step-table'><thead><tr><th>#</th><th>Call</th><th>Status</th>"
|
|
617
|
+
f"<th>Result</th></tr></thead><tbody>{step_rows(notable)}</tbody></table>{skipped_note}"
|
|
618
|
+
if traj.steps
|
|
619
|
+
else "<p class='muted'>No steps.</p>"
|
|
620
|
+
)
|
|
621
|
+
notes = ""
|
|
622
|
+
for a in traj.injections:
|
|
623
|
+
note_class = "injection-note" if a.valid else "injection-note injection-note--invalid"
|
|
624
|
+
validity = "" if a.valid else " — <strong>INVALID</strong>"
|
|
625
|
+
notes += (
|
|
626
|
+
f"<div class='{note_class}'>⚡ injection fired: <strong>{esc(a.injection.kind.value)}</strong> "
|
|
627
|
+
f"@ step {a.fired_at_step} (trigger: <code>{esc(a.trigger_kind)}</code>){validity}"
|
|
628
|
+
f"<br><span class='muted'>{esc(a.injection.intent)}</span></div>"
|
|
629
|
+
)
|
|
630
|
+
for e in traj.expired_injections:
|
|
631
|
+
notes += (
|
|
632
|
+
f"<div class='injection-note injection-note--expired'>armed but never fired: "
|
|
633
|
+
f"<strong>{esc(e.injection.kind.value)}</strong> (trigger: <code>{esc(e.trigger.describe())}</code>)</div>"
|
|
634
|
+
)
|
|
635
|
+
return (
|
|
636
|
+
f"<div class='run-label'>{esc(label)}</div>{table}"
|
|
637
|
+
f"<p class='muted'>final answer: {esc(_truncate(str(traj.final_answer), 200))}</p>{notes}"
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
clean_by_id = {t.scenario_id: t for t in self.clean_trajectories}
|
|
641
|
+
chaos_by_id = {t.scenario_id: t for t in self.chaos_trajectories}
|
|
642
|
+
scenario_ids = list(dict.fromkeys([*clean_by_id, *chaos_by_id]))
|
|
643
|
+
|
|
644
|
+
if not scenario_ids:
|
|
645
|
+
return "<p class='muted empty-state'>No scenarios run.</p>"
|
|
646
|
+
|
|
647
|
+
blocks = []
|
|
648
|
+
for scenario_id in scenario_ids:
|
|
649
|
+
clean = clean_by_id.get(scenario_id)
|
|
650
|
+
chaos = chaos_by_id.get(scenario_id)
|
|
651
|
+
pills = []
|
|
652
|
+
if clean is not None:
|
|
653
|
+
pills.append(
|
|
654
|
+
f"<span class='pill pill--{'pass' if clean.passed else 'fail'}'>clean: {'PASS' if clean.passed else 'FAIL'}</span>"
|
|
655
|
+
)
|
|
656
|
+
if chaos is not None:
|
|
657
|
+
pills.append(
|
|
658
|
+
f"<span class='pill pill--{'pass' if chaos.passed else 'fail'}'>chaos: {'PASS' if chaos.passed else 'FAIL'}</span>"
|
|
659
|
+
)
|
|
660
|
+
if chaos.discarded:
|
|
661
|
+
pills.append("<span class='pill pill--warn'>DISCARDED</span>")
|
|
662
|
+
blocks.append(
|
|
663
|
+
f"<details><summary>{esc(scenario_id)}<span class='tag-group'>{''.join(pills)}</span></summary>"
|
|
664
|
+
f"<div class='scenario-body'>{run_section('Clean run', clean)}{run_section('Chaos run', chaos)}</div></details>"
|
|
665
|
+
)
|
|
666
|
+
return "".join(blocks)
|