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/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]
|
agentprobe/quickstart.py
ADDED
|
@@ -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
|