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.
Files changed (55) hide show
  1. agentprobe/__init__.py +104 -0
  2. agentprobe/agents/__init__.py +0 -0
  3. agentprobe/agents/base.py +32 -0
  4. agentprobe/agents/rule_based.py +336 -0
  5. agentprobe/agents/scripted.py +30 -0
  6. agentprobe/agents/target_agent.py +106 -0
  7. agentprobe/agreement.py +80 -0
  8. agentprobe/classifier.py +159 -0
  9. agentprobe/cli.py +684 -0
  10. agentprobe/diff.py +150 -0
  11. agentprobe/domain.py +121 -0
  12. agentprobe/domains/__init__.py +0 -0
  13. agentprobe/domains/access_control/__init__.py +0 -0
  14. agentprobe/domains/access_control/agent.py +90 -0
  15. agentprobe/domains/access_control/clean.py +154 -0
  16. agentprobe/domains/access_control/complex_agent.py +123 -0
  17. agentprobe/domains/access_control/decoy.py +124 -0
  18. agentprobe/domains/access_control/domain.py +35 -0
  19. agentprobe/domains/access_control/entities.py +43 -0
  20. agentprobe/domains/access_control/injector_prompt.py +196 -0
  21. agentprobe/domains/access_control/rule_based_agent.py +263 -0
  22. agentprobe/domains/access_control/scenarios.py +17 -0
  23. agentprobe/domains/access_control/split.py +96 -0
  24. agentprobe/domains/access_control/tools.py +235 -0
  25. agentprobe/domains/access_control/trap.py +100 -0
  26. agentprobe/feedback.py +121 -0
  27. agentprobe/generic_world.py +99 -0
  28. agentprobe/injection.py +475 -0
  29. agentprobe/injector.py +810 -0
  30. agentprobe/llm.py +123 -0
  31. agentprobe/playbook.py +211 -0
  32. agentprobe/quickstart.py +295 -0
  33. agentprobe/reachability.py +196 -0
  34. agentprobe/registry.py +313 -0
  35. agentprobe/report.py +666 -0
  36. agentprobe/runner.py +317 -0
  37. agentprobe/scenario.py +75 -0
  38. agentprobe/scenarios/__init__.py +0 -0
  39. agentprobe/scenarios/clean.py +194 -0
  40. agentprobe/scenarios/decoy.py +272 -0
  41. agentprobe/scenarios/registry.py +16 -0
  42. agentprobe/scenarios/split.py +203 -0
  43. agentprobe/scenarios/trap.py +215 -0
  44. agentprobe/termui.py +154 -0
  45. agentprobe/tools.py +275 -0
  46. agentprobe/trajectory.py +107 -0
  47. agentprobe/triage.py +153 -0
  48. agentprobe/validate_scenarios.py +489 -0
  49. agentprobe/world.py +189 -0
  50. agentprobe_testing-0.5.0.dist-info/METADATA +127 -0
  51. agentprobe_testing-0.5.0.dist-info/RECORD +55 -0
  52. agentprobe_testing-0.5.0.dist-info/WHEEL +5 -0
  53. agentprobe_testing-0.5.0.dist-info/entry_points.txt +4 -0
  54. agentprobe_testing-0.5.0.dist-info/licenses/LICENSE +109 -0
  55. agentprobe_testing-0.5.0.dist-info/top_level.txt +1 -0
agentprobe/llm.py ADDED
@@ -0,0 +1,123 @@
1
+ """Shared helper for calling messages.create with best-effort determinism.
2
+
3
+ The anthropic SDK (1.0.0, latest on PyPI) has no typed `temperature`
4
+ parameter at all, but the raw API still validates and honors it for some
5
+ models -- reachable via `extra_body` (confirmed: an out-of-range value gets
6
+ a 400 with "temperature: range: 0..1", and temperature=0 measurably
7
+ converges completions vs the default). Newer models reject it outright
8
+ (observed: claude-sonnet-5 -> 400 "`temperature` is deprecated for this
9
+ model"). This probes once per model and falls back cleanly, instead of
10
+ either hardcoding a guess per call site or crashing every call for models
11
+ where it's unavailable.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ _TEMPERATURE_SUPPORTED: dict[str, bool] = {}
19
+
20
+
21
+ def create_deterministic(client: Any, **kwargs: Any) -> Any:
22
+ model = kwargs["model"]
23
+ if _TEMPERATURE_SUPPORTED.get(model, True):
24
+ try:
25
+ return client.messages.create(**kwargs, extra_body={"temperature": 0})
26
+ except Exception as e:
27
+ if "temperature" in str(e).lower():
28
+ _TEMPERATURE_SUPPORTED[model] = False
29
+ else:
30
+ raise
31
+ return client.messages.create(**kwargs)
32
+
33
+
34
+ _PRICE_PER_TOKEN: dict[str, tuple[float, float]] = {
35
+ # (input, output) USD per token, from the pricing table this was
36
+ # checked against (2026-08). Keyed by the exact model ids this
37
+ # codebase actually passes around.
38
+ "claude-haiku-4-5-20251001": (1e-6, 5e-6),
39
+ "claude-sonnet-5": (2e-6, 10e-6),
40
+ "claude-opus-5": (5e-6, 25e-6),
41
+ }
42
+ _PRICE_FALLBACK_BY_FAMILY: list[tuple[str, tuple[float, float]]] = [
43
+ # substring match against the model id, for a version not listed
44
+ # above (e.g. a dated snapshot) -- still prices at that family's
45
+ # current rate instead of silently defaulting to one fixed rate
46
+ # regardless of which model actually ran, which is the bug this
47
+ # replaces (cost tracking previously hardcoded Sonnet-era pricing for
48
+ # every model, over-costing Haiku runs ~5x and under-costing Opus
49
+ # runs ~5x on input).
50
+ ("haiku", (1e-6, 5e-6)),
51
+ ("sonnet", (2e-6, 10e-6)),
52
+ ("opus", (5e-6, 25e-6)),
53
+ ]
54
+
55
+
56
+ def cost_per_token(model: str) -> tuple[float, float]:
57
+ """(input, output) USD-per-token for `model`."""
58
+ if model in _PRICE_PER_TOKEN:
59
+ return _PRICE_PER_TOKEN[model]
60
+ lowered = model.lower()
61
+ for family, price in _PRICE_FALLBACK_BY_FAMILY:
62
+ if family in lowered:
63
+ return price
64
+ return _PRICE_PER_TOKEN["claude-sonnet-5"] # last-resort default
65
+
66
+
67
+ _CACHE_WRITE_MULTIPLIER = 1.25 # Anthropic prompt caching, 5-minute (ephemeral) TTL
68
+ _CACHE_READ_MULTIPLIER = 0.1 # -- fixed multiples of the model's own base input price,
69
+ # not separate absolute rates, and stable across model families; see
70
+ # https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
71
+
72
+
73
+ def compute_cost_usd(
74
+ model: str,
75
+ input_tokens: int,
76
+ output_tokens: int,
77
+ cache_creation_input_tokens: int = 0,
78
+ cache_read_input_tokens: int = 0,
79
+ ) -> float:
80
+ """Every live call site in this codebase sends its system prompt
81
+ through cacheable_system(), so a real response.usage nearly always has
82
+ non-zero cache_creation_input_tokens (first call in a ~5-min window)
83
+ or cache_read_input_tokens (every call after) -- neither is included
84
+ in input_tokens by the API, so omitting them here silently undercounts
85
+ the real bill on every single live run, not just an edge case."""
86
+ in_price, out_price = cost_per_token(model)
87
+ return (
88
+ input_tokens * in_price
89
+ + output_tokens * out_price
90
+ + cache_creation_input_tokens * in_price * _CACHE_WRITE_MULTIPLIER
91
+ + cache_read_input_tokens * in_price * _CACHE_READ_MULTIPLIER
92
+ )
93
+
94
+
95
+ def response_cost_usd(model: str, response: Any) -> float:
96
+ """compute_cost_usd from a raw messages.create response's usage --
97
+ the single call site every caller should use instead of reading
98
+ response.usage.input_tokens/output_tokens directly, so a mocked test
99
+ Usage (no cache_* attributes) and a real one (cache_* present, 0 when
100
+ caching wasn't triggered) both price correctly."""
101
+ usage = response.usage
102
+ return compute_cost_usd(
103
+ model,
104
+ usage.input_tokens,
105
+ usage.output_tokens,
106
+ getattr(usage, "cache_creation_input_tokens", 0) or 0,
107
+ getattr(usage, "cache_read_input_tokens", 0) or 0,
108
+ )
109
+
110
+
111
+ def cacheable_system(text: str) -> list[dict[str, Any]]:
112
+ """Wraps a plain-string system prompt into the block form prompt
113
+ caching requires, with cache_control on the (only) block. The
114
+ Injector/Target/Classifier system prompts are large, fixed, and
115
+ resent on nearly every call within a run -- a natural cache-hit
116
+ target. A cache hit is priced far below base input tokens (e.g.
117
+ Sonnet 5: $0.20/MTok vs $2/MTok base, per the pricing table this was
118
+ built against). No-op if the prompt is under the API's minimum
119
+ cacheable length for the model -- the API just serves it uncached,
120
+ same as before this existed. Cache writes cost slightly more than a
121
+ plain uncached call on the FIRST use within the ~5-minute TTL; the
122
+ savings come from every call after that within the window."""
123
+ return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
agentprobe/playbook.py ADDED
@@ -0,0 +1,211 @@
1
+ """Cross-run accumulated data on which trigger choices actually work,
2
+ keyed by structural features only (injection kind + scenario shape) --
3
+ never actual scenario/customer content (ticket text, customer names,
4
+ task descriptions). That's deliberate: aggregating structural outcome
5
+ statistics doesn't touch what a BYOK customer's data isolation promise
6
+ is actually about (their trajectories, their tickets), so it doesn't
7
+ undercut that pitch the way pooling raw trajectory content would.
8
+
9
+ v1: a local JSON file plus an advisory hint surfaced into the Injector's
10
+ prompt. This does NOT change the model in any way -- no fine-tuning, no
11
+ persistent state inside the model, every API call is still stateless.
12
+ The compounding advantage lives entirely in this file, which is then
13
+ pasted into the prompt as extra context each time. Whether the model
14
+ actually follows the hint is not guaranteed -- same as any other prompt
15
+ instruction -- this is a stronger prior than reasoning from scratch every
16
+ run, not a hard override of the model's judgment.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import sys
24
+ import tempfile
25
+ from collections import defaultdict
26
+ from dataclasses import asdict, dataclass
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from agentprobe.scenario import Scenario
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ScenarioShape:
35
+ """Structural features that generalize across customers/scenarios --
36
+ deliberately excludes anything content-specific."""
37
+
38
+ baseline_kind: str # "first_commit" | "fixed"
39
+ required_commits_count: int
40
+ domain: str = "ticket"
41
+ """Which Domain (see domain.py) this shape belongs to, e.g. "ticket"
42
+ or "access_control". Two domains' scenarios can easily land on the
43
+ same (baseline_kind, required_commits_count) -- without this, their
44
+ trigger-recommendation data would silently blend together in
45
+ Playbook.recommend(), giving the Injector a prior computed across
46
+ domains with potentially very different dynamics. Defaults to
47
+ "ticket" so a playbook.json written before this field existed (when
48
+ this project only had one domain) still loads and matches correctly."""
49
+
50
+ def key(self) -> str:
51
+ return f"{self.domain}:{self.baseline_kind}:{self.required_commits_count}"
52
+
53
+
54
+ def scenario_shape(scenario: Scenario, domain_name: str = "ticket") -> ScenarioShape:
55
+ baseline_kind = "first_commit" if scenario.baseline_until == "first_commit" else "fixed"
56
+ return ScenarioShape(
57
+ baseline_kind=baseline_kind,
58
+ required_commits_count=len(scenario.goal.required_commits),
59
+ domain=domain_name,
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class OutcomeRecord:
65
+ kind: str # InjectionKind value
66
+ trigger_kind: str # TriggerKind value
67
+ scenario_shape: ScenarioShape
68
+ fired: bool
69
+ valid: Optional[bool] = None # only meaningful if fired
70
+ handled: Optional[bool] = None # only meaningful if fired, valid, and classified
71
+ # Human judgment, from agentprobe/feedback.py's export/apply round trip --
72
+ # distinct axis from `handled`. `handled` is the classifier's mechanical
73
+ # verdict on whether the Target's response addressed the injection.
74
+ # `feedback` is free text: a customer's own words on what was good,
75
+ # what should change, and what was missing about this injection. None
76
+ # until someone writes it. Deliberately not a numeric rating -- forcing
77
+ # a customer's judgment into yes/no loses exactly the detail (WHY it
78
+ # was or wasn't realistic) that's useful to feed back to the Injector.
79
+ feedback: Optional[str] = None
80
+
81
+
82
+ class Playbook:
83
+ """Accumulates OutcomeRecords across runs into a local JSON file, and
84
+ answers "which trigger has historically worked best for this (kind,
85
+ shape)?" Read/write are both cheap, small-file operations -- this is
86
+ not meant to scale past thousands of records without moving to a real
87
+ database, which is a fine v2 problem to have.
88
+ """
89
+
90
+ def __init__(self, path: str = "~/.agentprobe/playbook.json"):
91
+ self._path = Path(path).expanduser()
92
+ self.records: list[OutcomeRecord] = self._load()
93
+
94
+ def _load(self) -> list[OutcomeRecord]:
95
+ if not self._path.exists():
96
+ return []
97
+ try:
98
+ with open(self._path) as f:
99
+ raw = json.load(f)
100
+ return [
101
+ OutcomeRecord(
102
+ kind=r["kind"],
103
+ trigger_kind=r["trigger_kind"],
104
+ scenario_shape=ScenarioShape(**r["scenario_shape"]),
105
+ fired=r["fired"],
106
+ valid=r.get("valid"),
107
+ handled=r.get("handled"),
108
+ feedback=r.get("feedback"),
109
+ )
110
+ for r in raw
111
+ ]
112
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
113
+ # A crash or interruption mid-write (see save()'s atomic-rename
114
+ # comment for why this used to be possible) or hand-editing
115
+ # gone wrong can leave this file unparsable. Losing accumulated
116
+ # playbook history is bad, but it's strictly better than every
117
+ # future --use-playbook run crashing with a traceback until
118
+ # someone finds and deletes the file by hand -- preserve the
119
+ # bad file for inspection and start fresh instead.
120
+ backup = self._path.with_suffix(self._path.suffix + ".corrupted")
121
+ try:
122
+ self._path.replace(backup)
123
+ note = f"moved to {backup}"
124
+ except OSError:
125
+ note = "could not be backed up"
126
+ print(
127
+ f"agentprobe: {self._path} was corrupted ({e}) -- {note}, starting a fresh playbook",
128
+ file=sys.stderr,
129
+ )
130
+ return []
131
+
132
+ def save(self) -> None:
133
+ self._path.parent.mkdir(parents=True, exist_ok=True)
134
+ payload = [
135
+ {
136
+ "kind": r.kind,
137
+ "trigger_kind": r.trigger_kind,
138
+ "scenario_shape": asdict(r.scenario_shape),
139
+ "fired": r.fired,
140
+ "valid": r.valid,
141
+ "handled": r.handled,
142
+ "feedback": r.feedback,
143
+ }
144
+ for r in self.records
145
+ ]
146
+ # Write to a temp file in the same directory, then atomically
147
+ # rename over the real path -- os.replace is atomic on both POSIX
148
+ # and Windows, so a crash or interruption mid-write can never leave
149
+ # a half-written, corrupted playbook.json behind; the old file
150
+ # stays intact (or the new one lands whole), never a partial mix.
151
+ fd, tmp_path = tempfile.mkstemp(dir=self._path.parent, prefix=".playbook-", suffix=".tmp")
152
+ try:
153
+ with os.fdopen(fd, "w") as f:
154
+ json.dump(payload, f, indent=2)
155
+ os.replace(tmp_path, self._path)
156
+ except BaseException:
157
+ try:
158
+ os.unlink(tmp_path)
159
+ except OSError:
160
+ pass
161
+ raise
162
+
163
+ def record(self, outcome: OutcomeRecord) -> None:
164
+ self.records.append(outcome)
165
+
166
+ def recommend(self, kind: str, shape: ScenarioShape, min_n: int = 10) -> Optional[dict]:
167
+ """Best-performing trigger_kind (by fire rate) for this (kind,
168
+ shape) combination, or None if there's fewer than min_n armed
169
+ attempts total -- callers should fall back to the existing
170
+ hard-coded default in that case, not act on noise from a handful
171
+ of runs. Fire rate, not handled rate: a trigger that never fires
172
+ can't be handled at all, and this is specifically about the
173
+ expiry problem this session spent most of its time on, not yet
174
+ about which trigger produces the best Target behavior once fired.
175
+ """
176
+ relevant = [r for r in self.records if r.kind == kind and r.scenario_shape == shape]
177
+ if len(relevant) < min_n:
178
+ return None
179
+
180
+ by_trigger: dict[str, list[OutcomeRecord]] = defaultdict(list)
181
+ for r in relevant:
182
+ by_trigger[r.trigger_kind].append(r)
183
+
184
+ best_trigger, best_rate, best_n = None, -1.0, 0
185
+ for trigger_kind, records in by_trigger.items():
186
+ n = len(records)
187
+ fire_rate = sum(1 for r in records if r.fired) / n
188
+ if fire_rate > best_rate:
189
+ best_trigger, best_rate, best_n = trigger_kind, fire_rate, n
190
+
191
+ if best_trigger is None:
192
+ return None
193
+ return {"trigger_kind": best_trigger, "fire_rate": best_rate, "n": best_n}
194
+
195
+ def recent_feedback(self, kind: str, shape: ScenarioShape, limit: int = 3) -> list[str]:
196
+ """Free-text customer notes for this (kind, shape), most recent
197
+ first -- from records a customer actually wrote via
198
+ agentprobe/feedback.py. Unlike recommend(), there's no rate to
199
+ average here (text doesn't reduce to a number), so this returns raw
200
+ notes for the Injector's prompt to read directly, capped at `limit`
201
+ so a long feedback history doesn't dominate the prompt. "Recent"
202
+ means "most recently appended to self.records" -- records don't
203
+ carry a timestamp, and append order is a good enough proxy since
204
+ feedback is always applied after the outcomes it comments on.
205
+ """
206
+ notes = [
207
+ r.feedback
208
+ for r in self.records
209
+ if r.kind == kind and r.scenario_shape == shape and r.feedback
210
+ ]
211
+ return notes[::-1][:limit]
@@ -0,0 +1,295 @@
1
+ """The lowest-friction way into this library: wrap_agent() turns a plain
2
+ function into a valid Target (no Agent subclass, no manual state), and
3
+ quick_test() collapses run_robustness_pair()+Report() into one call. Both
4
+ are pure convenience over the same public API in __init__.py -- nothing
5
+ here does anything the longer form couldn't already do, this just removes
6
+ the boilerplate for the common case of "I have a function that decides
7
+ what to do next, test it against one scenario."
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Optional
13
+
14
+ from agentprobe.agents.base import Agent, AgentAction
15
+ from agentprobe.domain import Domain, TICKET_DOMAIN
16
+ from agentprobe.injection import injection_was_triggered
17
+ from agentprobe.injector import Injector, NullInjector, RecordingInjector, serialize_armed_injection
18
+ from agentprobe.report import InjectionRecord, Report
19
+ from agentprobe.runner import run_recovery, run_robustness_pair
20
+ from agentprobe.scenario import Scenario
21
+
22
+ DecideFn = Callable[[str, list[dict[str, Any]]], AgentAction]
23
+
24
+
25
+ class _FunctionAgent(Agent):
26
+ """Adapts a plain decide_fn(task, history) -> AgentAction function to
27
+ the Agent interface. One instance per run (see wrap_agent), so state
28
+ never leaks between the clean and chaos trajectories of a robustness
29
+ pair -- the caller's function never has to think about that."""
30
+
31
+ def __init__(self, decide_fn: DecideFn):
32
+ self._decide_fn = decide_fn
33
+ self._task = ""
34
+ self._history: list[dict[str, Any]] = []
35
+
36
+ def start(self, task: str, tool_schemas: list[dict]) -> None:
37
+ self._task = task
38
+ self._history = []
39
+
40
+ def next_action(self) -> AgentAction:
41
+ return self._decide_fn(self._task, list(self._history))
42
+
43
+ def observe(self, tool_name: str, tool_args: dict, result: Any, ok: bool) -> None:
44
+ self._history.append({"tool_name": tool_name, "tool_args": tool_args, "result": result, "ok": ok})
45
+
46
+
47
+ def wrap_agent(decide_fn: DecideFn) -> Callable[[], Agent]:
48
+ """Wrap a plain function into a target_factory for run_robustness_pair/
49
+ run_recovery/quick_test. decide_fn receives (task, history) each turn --
50
+ history is everything observed so far this run, as a list of
51
+ {"tool_name", "tool_args", "result", "ok"} dicts -- and returns an
52
+ AgentAction (AgentAction(kind="tool_call", tool_name=..., tool_args=...)
53
+ or AgentAction(kind="final_answer", text=...)). No class, no manual
54
+ start()/observe() bookkeeping -- call whatever your real agent already
55
+ is from inside decide_fn and translate its answer into an AgentAction.
56
+ """
57
+ return lambda: _FunctionAgent(decide_fn)
58
+
59
+
60
+ def quick_test(
61
+ scenario: Scenario,
62
+ agent: Callable[[], Agent] | DecideFn,
63
+ injector: Optional[Injector] = None,
64
+ mode: str = "robustness",
65
+ target_model: str = "custom",
66
+ injector_model: Optional[str] = None,
67
+ domain: Domain = TICKET_DOMAIN,
68
+ upload_api_key: Optional[str] = None,
69
+ domain_key: Optional[str] = None,
70
+ record_injections: bool = False,
71
+ ) -> Report:
72
+ """Run one scenario and get back a ready Report -- the one-call version
73
+ of run_robustness_pair()/run_recovery() + Report(). `agent` can be a
74
+ target_factory (a zero-arg callable returning an Agent, e.g. RuleBasedAgent)
75
+ or a plain decide_fn(task, history) -> AgentAction, which gets wrap_agent()
76
+ applied automatically -- either way, nothing else here changes.
77
+
78
+ Pass `upload_api_key` (a personal key from /dashboard) to also upload the
79
+ finished report to your AgentProbe account, so it shows up under Your
80
+ Runs -- see registry.upload_run for what this does and its failure
81
+ behavior. Pass `domain_key` too if `domain` came from fetch_domain(), so
82
+ the uploaded run links back to it. Omit upload_api_key (the default) and
83
+ nothing is uploaded -- quick_test() stays fully local and offline.
84
+
85
+ Pass `record_injections=True` to wrap `injector` in a RecordingInjector
86
+ and, when uploading, include the recorded sequence so this exact run can
87
+ be replayed later via registry.replay_run() -- see its docstring. Has no
88
+ effect without upload_api_key (there'd be nowhere to store the recording).
89
+ """
90
+ injector = injector if injector is not None else NullInjector()
91
+ target_factory = agent if _looks_like_factory(agent) else wrap_agent(agent)
92
+ resolved_injector_model = injector_model if injector_model is not None else type(injector).__name__
93
+
94
+ recording: Optional[RecordingInjector] = None
95
+ if record_injections:
96
+ recording = RecordingInjector(injector)
97
+ injector = recording
98
+
99
+ if mode == "robustness":
100
+ clean, chaos = run_robustness_pair(scenario, target_factory, injector, domain=domain)
101
+ clean_trajectories = [clean]
102
+ else:
103
+ chaos = run_recovery(scenario, target_factory, injector, domain=domain)
104
+ clean_trajectories = []
105
+
106
+ # Report's "injections fired" stats come from injection_records, not
107
+ # from chaos_trajectories directly (see report.py's _compute) -- skip
108
+ # this and a quick_test() report would always show 0 fired regardless
109
+ # of what actually happened, same mistake the CLI has to avoid too.
110
+ injection_records = [
111
+ InjectionRecord(scenario_id=chaos.scenario_id, applied=a, triggered=injection_was_triggered(a, chaos.steps))
112
+ for a in chaos.injections
113
+ ]
114
+
115
+ report = Report(
116
+ mode=mode,
117
+ injector_model=resolved_injector_model,
118
+ target_model=target_model,
119
+ clean_trajectories=clean_trajectories,
120
+ chaos_trajectories=[chaos],
121
+ injection_records=injection_records,
122
+ injector_cost_usd=_injector_cost_usd(injector),
123
+ )
124
+ if recording is not None:
125
+ # Stashed here (not a Report field -- Report is a general-purpose
126
+ # shape other callers build by hand too) so a caller who skips
127
+ # upload_api_key here to call check_regression() first, then
128
+ # upload_run() manually, still gets the recording -- upload_run()
129
+ # auto-detects this attribute when its own recorded_injections
130
+ # param is omitted. See registry.upload_run.
131
+ report._recorded_injections = [serialize_armed_injection(a) for a in recording.recorded]
132
+
133
+ if upload_api_key is not None:
134
+ from agentprobe.registry import upload_run
135
+
136
+ upload_run(report, api_key=upload_api_key, domain_key=domain_key)
137
+
138
+ return report
139
+
140
+
141
+ def quick_test_all(
142
+ scenarios: dict[str, Scenario] | list[Scenario],
143
+ agent: Callable[[], Agent] | DecideFn,
144
+ injector_factory: Optional[Callable[[], Injector]] = None,
145
+ mode: str = "robustness",
146
+ target_model: str = "custom",
147
+ injector_model: Optional[str] = None,
148
+ domain: Domain = TICKET_DOMAIN,
149
+ upload_api_key: Optional[str] = None,
150
+ domain_key: Optional[str] = None,
151
+ record_injections: bool = False,
152
+ ) -> Report:
153
+ """Run every scenario in `scenarios` (a dict[str, Scenario] -- e.g.
154
+ TICKET_SCENARIOS or a FetchedDomain's .scenarios -- or a plain list of
155
+ Scenario) against the same agent, and combine every result into ONE
156
+ Report -- the batch version of calling quick_test() once per scenario
157
+ and merging the results by hand.
158
+
159
+ `injector_factory` is a zero-arg callable returning a FRESH Injector
160
+ instance per scenario (same shape as wrap_agent's target_factory), e.g.
161
+ `injector_factory=lambda: ModelInjector()`. This must be a factory, not
162
+ an already-constructed Injector -- several injectors (ModelInjector's
163
+ kind rotation, HardcodedToolErrorInjector's fired-offset tracking) carry
164
+ state across a run that must not leak between unrelated scenarios.
165
+ Omit it (the default) to run every scenario clean, with no chaos at all.
166
+
167
+ If `upload_api_key` is given, uploads EACH scenario's result as its own
168
+ run (so Your Runs shows them individually, the same granularity as
169
+ calling quick_test() per scenario and uploading each) -- the Report
170
+ this function returns is still the combined summary across all of them.
171
+ """
172
+ items = list(scenarios.items()) if isinstance(scenarios, dict) else [(s.id, s) for s in scenarios]
173
+ if not items:
174
+ raise ValueError("quick_test_all requires at least one scenario")
175
+
176
+ target_factory = agent if _looks_like_factory(agent) else wrap_agent(agent)
177
+ make_injector = injector_factory if injector_factory is not None else (lambda: NullInjector())
178
+ if not callable(make_injector):
179
+ raise ValueError(
180
+ "injector_factory must be a zero-arg callable returning a fresh Injector each time "
181
+ "(e.g. lambda: ModelInjector()), not an already-constructed Injector instance"
182
+ )
183
+
184
+ all_clean: list = []
185
+ all_chaos: list = []
186
+ all_records: list[InjectionRecord] = []
187
+ total_injector_cost = 0.0
188
+ combined_injector_model: Optional[str] = None
189
+
190
+ for _scenario_id, scenario in items:
191
+ injector = make_injector()
192
+ resolved_injector_model = injector_model if injector_model is not None else type(injector).__name__
193
+ if combined_injector_model is None:
194
+ combined_injector_model = resolved_injector_model
195
+
196
+ recording: Optional[RecordingInjector] = None
197
+ if record_injections:
198
+ recording = RecordingInjector(injector)
199
+ injector = recording
200
+
201
+ if mode == "robustness":
202
+ clean, chaos = run_robustness_pair(scenario, target_factory, injector, domain=domain)
203
+ all_clean.append(clean)
204
+ else:
205
+ clean = None
206
+ chaos = run_recovery(scenario, target_factory, injector, domain=domain)
207
+ all_chaos.append(chaos)
208
+
209
+ records = [
210
+ InjectionRecord(scenario_id=chaos.scenario_id, applied=a, triggered=injection_was_triggered(a, chaos.steps))
211
+ for a in chaos.injections
212
+ ]
213
+ all_records.extend(records)
214
+ injector_cost = _injector_cost_usd(injector)
215
+ total_injector_cost += injector_cost
216
+
217
+ if upload_api_key is not None:
218
+ from agentprobe.registry import upload_run
219
+
220
+ scenario_report = Report(
221
+ mode=mode,
222
+ injector_model=resolved_injector_model,
223
+ target_model=target_model,
224
+ clean_trajectories=[clean] if clean is not None else [],
225
+ chaos_trajectories=[chaos],
226
+ injection_records=records,
227
+ injector_cost_usd=injector_cost,
228
+ )
229
+ recorded = [serialize_armed_injection(a) for a in recording.recorded] if recording is not None else None
230
+ upload_run(scenario_report, api_key=upload_api_key, domain_key=domain_key, recorded_injections=recorded)
231
+
232
+ return Report(
233
+ mode=mode,
234
+ injector_model=combined_injector_model or "none",
235
+ target_model=target_model,
236
+ clean_trajectories=all_clean,
237
+ chaos_trajectories=all_chaos,
238
+ injection_records=all_records,
239
+ injector_cost_usd=total_injector_cost,
240
+ )
241
+
242
+
243
+ def assert_passes(report: Report, save_html_on_failure: Optional[str] = None) -> None:
244
+ """Assert every scenario in `report` passed -- chaos, and clean too when
245
+ this was a robustness-mode run -- the one-line pytest integration: call
246
+ this at the end of a test instead of hand-picking which Report fields
247
+ to check.
248
+
249
+ Raises AssertionError with a summary of what failed if not, so this
250
+ reads naturally as the last line of a pytest test function:
251
+
252
+ report = quick_test(scenario, my_agent, injector=ModelInjector())
253
+ assert_passes(report)
254
+
255
+ Pass `save_html_on_failure="path.html"` to also write the full
256
+ render_html() report there when the assertion fails, so a failed CI run
257
+ leaves something to open instead of just a pass/fail count.
258
+ """
259
+ c = report._compute()
260
+ failures = []
261
+ if c["clean_passed"] is not None and c["clean_passed"] != c["clean_total"]:
262
+ failures.append(f"clean pass rate {c['clean_passed']}/{c['clean_total']}")
263
+ if c["chaos_passed"] != c["n"]:
264
+ failures.append(f"chaos pass rate {c['chaos_passed']}/{c['n']}")
265
+ if not failures:
266
+ return
267
+ if save_html_on_failure:
268
+ with open(save_html_on_failure, "w") as f:
269
+ f.write(report.render_html())
270
+ failures.append(f"full report written to {save_html_on_failure}")
271
+ raise AssertionError("agentprobe: " + "; ".join(failures))
272
+
273
+
274
+ def _injector_cost_usd(injector: Injector) -> float:
275
+ """ModelInjector is the only injector type with real API cost (none/
276
+ hardcoded/replay never call a model) -- same logic as cli.py's version,
277
+ duplicated rather than imported to keep quickstart.py's only dependency
278
+ on cli.py at zero. Unwraps RecordingInjector's wrapping too, since it
279
+ doesn't have its own total_cost_usd."""
280
+ inner = getattr(injector, "_inner", injector)
281
+ return getattr(inner, "total_cost_usd", 0.0)
282
+
283
+
284
+ def _looks_like_factory(agent: Callable[[], Agent] | DecideFn) -> bool:
285
+ """A target_factory takes zero arguments; a decide_fn takes two
286
+ (task, history). Distinguish by arity rather than asking the caller to
287
+ say which one they passed."""
288
+ import inspect
289
+
290
+ try:
291
+ params = inspect.signature(agent).parameters
292
+ except (TypeError, ValueError):
293
+ return True
294
+ required = [p for p in params.values() if p.default is p.empty and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
295
+ return len(required) == 0