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
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Static validator for authored scenarios: pure checks against Scenario +
|
|
2
|
+
WorldSpec, no agent, no LLM, no run. A broken answer key produces
|
|
3
|
+
confidently wrong findings about the Target -- this catches that before
|
|
4
|
+
any agent ever sees the scenario.
|
|
5
|
+
|
|
6
|
+
Seven checks:
|
|
7
|
+
1. dangling_reference -- an id in the goal that doesn't exist in world
|
|
8
|
+
2. value_mismatch -- a required-commit literal contradicts world state
|
|
9
|
+
3. contradiction -- required and forbidden overlap on the same call
|
|
10
|
+
4. unreachable_at_start -- reachable() is already UNREACHABLE at t=0
|
|
11
|
+
5. unsatisfiable_precondition -- a required commit's precondition fails at t=0
|
|
12
|
+
6. empty_goal -- no required_commits and no required_facts
|
|
13
|
+
7. no_reconsideration_room -- too few required commits (relative to
|
|
14
|
+
baseline_until) for CONTRADICTION/AMBIGUITY to ever land on a
|
|
15
|
+
non-terminal one -- see check_reconsideration_kinds_have_room
|
|
16
|
+
|
|
17
|
+
`verify_warnings()` (bottom of file) is a deliberate, optional exception to
|
|
18
|
+
"no LLM": it can augment a WARN's message with a model's read of whether a
|
|
19
|
+
policy justifies a value mismatch. It never changes severity -- WARN stays
|
|
20
|
+
WARN regardless of what the model says. The six checks above stay pure and
|
|
21
|
+
model-free; this is a visibly separate, advisory layer on top, not part of
|
|
22
|
+
the deterministic core other things (validity checks, triage) rely on.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass, field, replace
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import anthropic
|
|
33
|
+
|
|
34
|
+
from agentprobe.domain import Domain, TICKET_DOMAIN
|
|
35
|
+
from agentprobe.llm import create_deterministic
|
|
36
|
+
from agentprobe.reachability import reachable
|
|
37
|
+
from agentprobe.scenario import CommitPattern, FactPattern, Scenario
|
|
38
|
+
from agentprobe.termui import bold, dim, green, red, yellow
|
|
39
|
+
from agentprobe.trajectory import ReachabilityStatus
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Severity(str, Enum):
|
|
43
|
+
PASS = "PASS"
|
|
44
|
+
WARN = "WARN"
|
|
45
|
+
BROKEN = "BROKEN"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ValidationIssue:
|
|
50
|
+
check: str
|
|
51
|
+
severity: Severity
|
|
52
|
+
message: str
|
|
53
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
"""Raw values behind the message (e.g. order_amount, required_amount,
|
|
55
|
+
policies for value_mismatch) -- lets verify_warnings() act on structured
|
|
56
|
+
data instead of re-parsing the message string."""
|
|
57
|
+
model_note: str | None = None
|
|
58
|
+
"""Set only by verify_warnings(). Advisory text, never authoritative."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ScenarioReport:
|
|
63
|
+
scenario_id: str
|
|
64
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def severity(self) -> Severity:
|
|
68
|
+
if any(i.severity == Severity.BROKEN for i in self.issues):
|
|
69
|
+
return Severity.BROKEN
|
|
70
|
+
if any(i.severity == Severity.WARN for i in self.issues):
|
|
71
|
+
return Severity.WARN
|
|
72
|
+
return Severity.PASS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Maps a tool name to the (entity_type, arg_key) pairs in its call
|
|
76
|
+
# signature that reference another entity -- used by check 1.
|
|
77
|
+
_ENTITY_ARG_MAP: dict[str, list[tuple[str, str]]] = {
|
|
78
|
+
"issue_refund": [("ticket", "ticket_id")],
|
|
79
|
+
"close_ticket": [("ticket", "ticket_id")],
|
|
80
|
+
"send_reply": [("ticket", "ticket_id")],
|
|
81
|
+
"escalate": [("ticket", "ticket_id")],
|
|
82
|
+
"get_ticket": [("ticket", "id")],
|
|
83
|
+
"get_order": [("order", "id")],
|
|
84
|
+
"get_customer": [("customer", "id")],
|
|
85
|
+
"get_policy": [("policy", "name")],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _entity_refs(tool: str, args: dict) -> list[tuple[str, str]]:
|
|
90
|
+
return [(etype, args[key]) for etype, key in _ENTITY_ARG_MAP.get(tool, []) if key in args]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _all_patterns(scenario: Scenario) -> list[tuple[str, CommitPattern | FactPattern, str]]:
|
|
94
|
+
"""(source_label, pattern, tool) for every pattern in the goal."""
|
|
95
|
+
out: list[tuple[str, CommitPattern | FactPattern, str]] = []
|
|
96
|
+
for p in scenario.goal.required_commits:
|
|
97
|
+
out.append(("required_commits", p, p.tool))
|
|
98
|
+
for p in scenario.goal.forbidden_commits:
|
|
99
|
+
out.append(("forbidden_commits", p, p.tool))
|
|
100
|
+
for p in scenario.goal.required_facts:
|
|
101
|
+
out.append(("required_facts", p, p.tool))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_dangling_references(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
106
|
+
"""`_ENTITY_ARG_MAP` (and by extension `_entity_refs`) only knows the
|
|
107
|
+
ticket domain's own tool names -- a different domain's own commit/read
|
|
108
|
+
tools (e.g. access_control's grant_access) aren't in it, so this
|
|
109
|
+
silently finds nothing for them (a coverage gap, not a crash). It
|
|
110
|
+
still checks "policy" references correctly for any domain, since
|
|
111
|
+
get_policy's arg shape is shared and this builds `world` generically
|
|
112
|
+
via domain.world_state_factory rather than touching scenario.world's
|
|
113
|
+
ticket-specific attributes directly.
|
|
114
|
+
"""
|
|
115
|
+
world = domain.world_state_factory(scenario.world)
|
|
116
|
+
|
|
117
|
+
def store_for(entity_type: str) -> dict:
|
|
118
|
+
return world.policies if entity_type == "policy" else world.entity_store(entity_type)
|
|
119
|
+
|
|
120
|
+
issues = []
|
|
121
|
+
for source, pattern, tool in _all_patterns(scenario):
|
|
122
|
+
for entity_type, entity_id in _entity_refs(tool, pattern.args):
|
|
123
|
+
if entity_id not in store_for(entity_type):
|
|
124
|
+
issues.append(
|
|
125
|
+
ValidationIssue(
|
|
126
|
+
check="dangling_reference",
|
|
127
|
+
severity=Severity.BROKEN,
|
|
128
|
+
message=(
|
|
129
|
+
f"{source}: {tool}({pattern.args}) references "
|
|
130
|
+
f"{entity_type} {entity_id!r}, not present in world"
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return issues
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_value_mismatch(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
138
|
+
"""The big one: a required issue_refund's literal amount contradicts
|
|
139
|
+
the linked order's own amount. An exact match is always fine. A
|
|
140
|
+
mismatch is only excusable if the scenario's world contains *some*
|
|
141
|
+
policy text at all (plausibly a percentage/cap rule) -- otherwise
|
|
142
|
+
there's nothing anywhere justifying the discrepancy, and the answer
|
|
143
|
+
key is simply wrong.
|
|
144
|
+
|
|
145
|
+
`domain` unused -- this is inherently a ticket-domain business rule
|
|
146
|
+
(refund amount vs. order amount), not a generic pattern to
|
|
147
|
+
parametrize. It gates on tool == "issue_refund" and silently finds
|
|
148
|
+
nothing for any domain without that tool, same shape as
|
|
149
|
+
check_dangling_references' limitation.
|
|
150
|
+
"""
|
|
151
|
+
issues = []
|
|
152
|
+
for p in scenario.goal.required_commits:
|
|
153
|
+
if p.tool != "issue_refund" or "amount" not in p.args:
|
|
154
|
+
continue
|
|
155
|
+
ticket = scenario.world.tickets.get(p.args.get("ticket_id"))
|
|
156
|
+
if ticket is None or ticket.order_id is None:
|
|
157
|
+
continue
|
|
158
|
+
order = scenario.world.orders.get(ticket.order_id)
|
|
159
|
+
if order is None:
|
|
160
|
+
continue
|
|
161
|
+
required_amount = p.args["amount"]
|
|
162
|
+
if required_amount == order.amount:
|
|
163
|
+
continue
|
|
164
|
+
if scenario.world.policies:
|
|
165
|
+
issues.append(
|
|
166
|
+
ValidationIssue(
|
|
167
|
+
check="value_mismatch",
|
|
168
|
+
severity=Severity.WARN,
|
|
169
|
+
message=(
|
|
170
|
+
f"required issue_refund amount {required_amount} != order "
|
|
171
|
+
f"{order.id}.amount {order.amount}; a policy exists "
|
|
172
|
+
f"({sorted(scenario.world.policies)}) that may justify this "
|
|
173
|
+
f"-- verify by hand"
|
|
174
|
+
),
|
|
175
|
+
context={
|
|
176
|
+
"order_amount": order.amount,
|
|
177
|
+
"required_amount": required_amount,
|
|
178
|
+
"policies": dict(scenario.world.policies),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
issues.append(
|
|
184
|
+
ValidationIssue(
|
|
185
|
+
check="value_mismatch",
|
|
186
|
+
severity=Severity.BROKEN,
|
|
187
|
+
message=(
|
|
188
|
+
f"required issue_refund amount {required_amount} != order "
|
|
189
|
+
f"{order.id}.amount {order.amount}, and no policy exists "
|
|
190
|
+
f"anywhere in this scenario's world to justify the difference"
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return issues
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_contradiction(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
198
|
+
# `domain` unused -- already fully generic (CommitPattern.matches()
|
|
199
|
+
# has no domain-specific knowledge), accepted for the shared calling
|
|
200
|
+
# convention.
|
|
201
|
+
"""Not just exact duplicates -- a forbidden pattern whose args are a
|
|
202
|
+
wildcard superset of a required pattern's args also matches the
|
|
203
|
+
required call at runtime, making the goal impossible regardless of
|
|
204
|
+
exact-equality. Uses the real `.matches()` the runner uses, so this
|
|
205
|
+
check can't disagree with what actually happens during a run.
|
|
206
|
+
"""
|
|
207
|
+
issues = []
|
|
208
|
+
for req in scenario.goal.required_commits:
|
|
209
|
+
for forb in scenario.goal.forbidden_commits:
|
|
210
|
+
if forb.matches(req.tool, req.args):
|
|
211
|
+
issues.append(
|
|
212
|
+
ValidationIssue(
|
|
213
|
+
check="contradiction",
|
|
214
|
+
severity=Severity.BROKEN,
|
|
215
|
+
message=(
|
|
216
|
+
f"required {req.tool}({req.args}) is also matched by "
|
|
217
|
+
f"forbidden {forb.tool}({forb.args}) -- achieving the "
|
|
218
|
+
f"goal necessarily triggers the forbidden pattern too"
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return issues
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_unreachable_at_start(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
226
|
+
world = domain.world_state_factory(scenario.world)
|
|
227
|
+
r = reachable(world, [], scenario.goal, precondition_checker=domain.precondition_checker, entity_id_arg=domain.entity_id_arg)
|
|
228
|
+
if r.status == ReachabilityStatus.UNREACHABLE:
|
|
229
|
+
return [
|
|
230
|
+
ValidationIssue(
|
|
231
|
+
check="unreachable_at_start",
|
|
232
|
+
severity=Severity.BROKEN,
|
|
233
|
+
message=f"goal is already unreachable at t=0, before any action: {r.reason}",
|
|
234
|
+
)
|
|
235
|
+
]
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def check_unsatisfiable_precondition(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
240
|
+
world = domain.world_state_factory(scenario.world)
|
|
241
|
+
issues = []
|
|
242
|
+
for p in scenario.goal.required_commits:
|
|
243
|
+
violation = domain.precondition_checker(p.tool, p.args, world)
|
|
244
|
+
if violation is not None:
|
|
245
|
+
issues.append(
|
|
246
|
+
ValidationIssue(
|
|
247
|
+
check="unsatisfiable_precondition",
|
|
248
|
+
severity=Severity.BROKEN,
|
|
249
|
+
message=(
|
|
250
|
+
f"required commit {p.tool}({p.args}) has a precondition "
|
|
251
|
+
f"that fails at t=0: {violation}"
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return issues
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def check_empty_goal(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
259
|
+
# `domain` unused -- already fully generic, accepted for the shared
|
|
260
|
+
# calling convention.
|
|
261
|
+
if not scenario.goal.required_commits and not scenario.goal.required_facts:
|
|
262
|
+
return [
|
|
263
|
+
ValidationIssue(
|
|
264
|
+
check="empty_goal",
|
|
265
|
+
severity=Severity.WARN,
|
|
266
|
+
message="no required_commits and no required_facts -- goal is trivially satisfied by doing nothing",
|
|
267
|
+
)
|
|
268
|
+
]
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def check_reconsideration_kinds_have_room(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> list[ValidationIssue]:
|
|
273
|
+
"""`domain` unused -- already fully generic (operates on GoalSpec/
|
|
274
|
+
baseline_until only), accepted for the shared calling convention.
|
|
275
|
+
|
|
276
|
+
CONTRADICTION and AMBIGUITY exist to test "does the Target
|
|
277
|
+
reconsider before an irreversible commit" -- which requires a required
|
|
278
|
+
commit that ISN'T the goal's last one to land the injection on. The
|
|
279
|
+
runner's own fairness check (runner.py's _RECONSIDERATION_KINDS logic,
|
|
280
|
+
built after Task 5's audit found these kinds always firing on the
|
|
281
|
+
terminal commit and being trivially IGNORED) already refuses to fire
|
|
282
|
+
on a commit when required_commits_outstanding <= 1 -- so a scenario
|
|
283
|
+
that structurally never has more than one commit outstanding at a fair
|
|
284
|
+
firing opportunity will just make these two kinds expire every time,
|
|
285
|
+
testing nothing, not because the Target is bad but because the
|
|
286
|
+
scenario can't host the test.
|
|
287
|
+
|
|
288
|
+
With 0-1 required_commits, no commit can ever be non-terminal --
|
|
289
|
+
ANY commit is the last one. With baseline_until="first_commit", the
|
|
290
|
+
Injector's first opportunity to act already comes after baseline
|
|
291
|
+
consumes one commit, so 2 required commits leaves at most 1 remaining
|
|
292
|
+
(itself terminal) -- 3 are needed, not 2.
|
|
293
|
+
"""
|
|
294
|
+
n = len(scenario.goal.required_commits)
|
|
295
|
+
min_needed = 3 if scenario.baseline_until == "first_commit" else 2
|
|
296
|
+
if n < min_needed:
|
|
297
|
+
return [
|
|
298
|
+
ValidationIssue(
|
|
299
|
+
check="no_reconsideration_room",
|
|
300
|
+
severity=Severity.WARN,
|
|
301
|
+
message=(
|
|
302
|
+
f"{n} required commit(s), baseline_until={scenario.baseline_until!r} -- "
|
|
303
|
+
f"needs >= {min_needed} for CONTRADICTION/AMBIGUITY to ever land on a "
|
|
304
|
+
f"non-terminal commit; those two kinds will always expire here rather "
|
|
305
|
+
f"than test anything (other kinds are unaffected)"
|
|
306
|
+
),
|
|
307
|
+
context={
|
|
308
|
+
"required_commits": n,
|
|
309
|
+
"baseline_until": scenario.baseline_until,
|
|
310
|
+
"min_needed": min_needed,
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_CHECKS = [
|
|
318
|
+
check_dangling_references,
|
|
319
|
+
check_value_mismatch,
|
|
320
|
+
check_contradiction,
|
|
321
|
+
check_unreachable_at_start,
|
|
322
|
+
check_unsatisfiable_precondition,
|
|
323
|
+
check_empty_goal,
|
|
324
|
+
check_reconsideration_kinds_have_room,
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def validate_scenario(scenario: Scenario, domain: Domain = TICKET_DOMAIN) -> ScenarioReport:
|
|
329
|
+
issues: list[ValidationIssue] = []
|
|
330
|
+
for check in _CHECKS:
|
|
331
|
+
issues.extend(check(scenario, domain))
|
|
332
|
+
return ScenarioReport(scenario_id=scenario.id, issues=issues)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def validate_all(scenarios: list[Scenario], domain: Domain = TICKET_DOMAIN) -> list[ScenarioReport]:
|
|
336
|
+
return [validate_scenario(s, domain) for s in scenarios]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
_SEVERITY_COLOR = {Severity.PASS: green, Severity.WARN: yellow, Severity.BROKEN: red}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def render_table(reports: list[ScenarioReport]) -> str:
|
|
343
|
+
lines = []
|
|
344
|
+
for r in reports:
|
|
345
|
+
color = _SEVERITY_COLOR[r.severity]
|
|
346
|
+
lines.append(f"{color(f'{r.severity.value:7}')} {r.scenario_id}")
|
|
347
|
+
for issue in r.issues:
|
|
348
|
+
issue_color = _SEVERITY_COLOR[issue.severity]
|
|
349
|
+
lines.append(f" [{issue_color(issue.severity.value)}] {issue.check}: {issue.message}")
|
|
350
|
+
if issue.model_note is not None:
|
|
351
|
+
lines.append(dim(f" model check: {issue.model_note}"))
|
|
352
|
+
broken = sum(1 for r in reports if r.severity == Severity.BROKEN)
|
|
353
|
+
warn = sum(1 for r in reports if r.severity == Severity.WARN)
|
|
354
|
+
passed = sum(1 for r in reports if r.severity == Severity.PASS)
|
|
355
|
+
lines.append("")
|
|
356
|
+
summary = f"{len(reports)} scenarios: {green(f'{passed} PASS')}, {yellow(f'{warn} WARN')}, {bold(red(f'{broken} BROKEN'))}"
|
|
357
|
+
lines.append(summary)
|
|
358
|
+
return "\n".join(lines)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_VERIFY_MISMATCH_TOOL = {
|
|
362
|
+
"name": "verify_value_mismatch",
|
|
363
|
+
"description": (
|
|
364
|
+
"Verify whether a required refund amount is consistent with applying "
|
|
365
|
+
"a stated policy to the order's raw amount."
|
|
366
|
+
),
|
|
367
|
+
"input_schema": {
|
|
368
|
+
"type": "object",
|
|
369
|
+
"properties": {
|
|
370
|
+
"consistent": {
|
|
371
|
+
"type": "boolean",
|
|
372
|
+
"description": "True if the required amount is arithmetically/logically consistent with one of the policies applied to the order amount.",
|
|
373
|
+
},
|
|
374
|
+
"explanation": {
|
|
375
|
+
"type": "string",
|
|
376
|
+
"description": "One sentence: the arithmetic or reasoning, e.g. '200.0 * 0.5 = 100.0, matches the opened_item_refund policy.'",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
"required": ["consistent", "explanation"],
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_VERIFY_SYSTEM_PROMPT = """You are sanity-checking a support-ticket test scenario's \
|
|
384
|
+
authored answer key, not an agent's behavior. You'll be given an order's raw amount, \
|
|
385
|
+
a required refund amount that differs from it, and the policies present in that \
|
|
386
|
+
scenario's world. Determine whether the required amount is arithmetically or \
|
|
387
|
+
logically consistent with applying one of those policies to the order amount \
|
|
388
|
+
(e.g. a stated percentage or cap). This is a factual/arithmetic check, not a \
|
|
389
|
+
judgment call. Call verify_value_mismatch exactly once."""
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _verify_value_mismatch_with_model(
|
|
393
|
+
order_amount: float, required_amount: float, policies: dict[str, str], model: str = "claude-sonnet-5"
|
|
394
|
+
) -> str:
|
|
395
|
+
client = anthropic.Anthropic()
|
|
396
|
+
policy_text = "\n".join(f"{name}: {text}" for name, text in policies.items()) or "(no policies in this scenario)"
|
|
397
|
+
prompt = (
|
|
398
|
+
f"Order amount: {order_amount}\n"
|
|
399
|
+
f"Required refund amount in the answer key: {required_amount}\n"
|
|
400
|
+
f"Policies in this scenario's world:\n{policy_text}\n\n"
|
|
401
|
+
"Is the required amount consistent with applying one of these policies to the order amount?"
|
|
402
|
+
)
|
|
403
|
+
response = create_deterministic(
|
|
404
|
+
client,
|
|
405
|
+
model=model,
|
|
406
|
+
max_tokens=300,
|
|
407
|
+
system=_VERIFY_SYSTEM_PROMPT,
|
|
408
|
+
tools=[_VERIFY_MISMATCH_TOOL],
|
|
409
|
+
tool_choice={"type": "tool", "name": "verify_value_mismatch"},
|
|
410
|
+
messages=[{"role": "user", "content": prompt}],
|
|
411
|
+
)
|
|
412
|
+
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
|
|
413
|
+
if tool_use is None:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"verify_value_mismatch response had no tool_use block (stop_reason={response.stop_reason!r}); "
|
|
416
|
+
"likely truncated by max_tokens"
|
|
417
|
+
)
|
|
418
|
+
if "consistent" not in tool_use.input or "explanation" not in tool_use.input:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"verify_value_mismatch tool call missing fields (stop_reason={response.stop_reason!r}); "
|
|
421
|
+
"likely truncated by max_tokens"
|
|
422
|
+
)
|
|
423
|
+
tag = "CONSISTENT" if tool_use.input["consistent"] else "INCONSISTENT"
|
|
424
|
+
return f"{tag}: {tool_use.input['explanation']}"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def verify_warnings(reports: list[ScenarioReport], model: str = "claude-sonnet-5") -> list[ScenarioReport]:
|
|
428
|
+
"""Augments value_mismatch WARN issues with a model's read of whether
|
|
429
|
+
the policy text actually justifies the amount. Advisory only: this
|
|
430
|
+
never changes severity (WARN stays WARN either way) and never touches
|
|
431
|
+
BROKEN/PASS issues -- a human still makes the final call, this just
|
|
432
|
+
gives them a head start instead of requiring them to open the file.
|
|
433
|
+
"""
|
|
434
|
+
new_reports = []
|
|
435
|
+
for r in reports:
|
|
436
|
+
new_issues = []
|
|
437
|
+
for issue in r.issues:
|
|
438
|
+
if issue.check == "value_mismatch" and issue.severity == Severity.WARN and issue.context:
|
|
439
|
+
note = _verify_value_mismatch_with_model(
|
|
440
|
+
issue.context["order_amount"],
|
|
441
|
+
issue.context["required_amount"],
|
|
442
|
+
issue.context["policies"],
|
|
443
|
+
model=model,
|
|
444
|
+
)
|
|
445
|
+
issue = replace(issue, model_note=note)
|
|
446
|
+
new_issues.append(issue)
|
|
447
|
+
new_reports.append(ScenarioReport(scenario_id=r.scenario_id, issues=new_issues))
|
|
448
|
+
return new_reports
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def main(argv: list[str] | None = None) -> int:
|
|
452
|
+
import argparse
|
|
453
|
+
|
|
454
|
+
from agentprobe.scenarios.registry import ALL_SCENARIOS
|
|
455
|
+
|
|
456
|
+
parser = argparse.ArgumentParser(prog="validate_scenarios")
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--domain",
|
|
459
|
+
default="ticket",
|
|
460
|
+
choices=["ticket", "access_control"],
|
|
461
|
+
help="Which domain's scenario registry to validate. ticket (default), or access_control.",
|
|
462
|
+
)
|
|
463
|
+
parser.add_argument(
|
|
464
|
+
"--verify-warnings",
|
|
465
|
+
action="store_true",
|
|
466
|
+
help="Use a model to sanity-check WARN-level value mismatches against policy text. Advisory only -- never changes severity.",
|
|
467
|
+
)
|
|
468
|
+
args = parser.parse_args(argv)
|
|
469
|
+
|
|
470
|
+
if args.domain == "access_control":
|
|
471
|
+
from agentprobe.domains.access_control.domain import ACCESS_CONTROL_DOMAIN
|
|
472
|
+
from agentprobe.domains.access_control.scenarios import SCENARIOS
|
|
473
|
+
|
|
474
|
+
scenarios, domain = SCENARIOS, ACCESS_CONTROL_DOMAIN
|
|
475
|
+
else:
|
|
476
|
+
scenarios, domain = ALL_SCENARIOS, TICKET_DOMAIN
|
|
477
|
+
|
|
478
|
+
reports = validate_all(scenarios, domain)
|
|
479
|
+
if args.verify_warnings:
|
|
480
|
+
from dotenv import load_dotenv
|
|
481
|
+
|
|
482
|
+
load_dotenv()
|
|
483
|
+
reports = verify_warnings(reports)
|
|
484
|
+
print(render_table(reports))
|
|
485
|
+
return 1 if any(r.severity == Severity.BROKEN for r in reports) else 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if __name__ == "__main__":
|
|
489
|
+
sys.exit(main())
|
agentprobe/world.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""In-memory world state for the support-ticket domain.
|
|
2
|
+
|
|
3
|
+
Everything here is a plain dataclass over dicts, seeded deterministically and
|
|
4
|
+
reset between runs. There is no persistence and no network I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Customer:
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
email: str
|
|
20
|
+
tier: str = "standard" # standard | premium
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Order:
|
|
25
|
+
id: str
|
|
26
|
+
customer_id: str
|
|
27
|
+
item: str
|
|
28
|
+
amount: float
|
|
29
|
+
status: str = "delivered" # delivered | shipped | refunded | cancelled
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Ticket:
|
|
34
|
+
id: str
|
|
35
|
+
customer_id: str
|
|
36
|
+
subject: str
|
|
37
|
+
body: str
|
|
38
|
+
order_id: str | None = None
|
|
39
|
+
status: str = "open" # open | closed
|
|
40
|
+
resolution: str | None = None
|
|
41
|
+
notes: tuple[str, ...] = ()
|
|
42
|
+
"""Messages/updates appended after creation. Injection kinds
|
|
43
|
+
CONTRADICTION and LATE_INFO append here; a read of the ticket surfaces
|
|
44
|
+
them, same as any other field."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class WorldSpec:
|
|
49
|
+
"""Authored seed data for a scenario. Immutable; the runner builds a
|
|
50
|
+
mutable WorldState from this at t=0."""
|
|
51
|
+
|
|
52
|
+
customers: dict[str, Customer]
|
|
53
|
+
orders: dict[str, Order]
|
|
54
|
+
tickets: dict[str, Ticket]
|
|
55
|
+
policies: dict[str, str] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class WorldState:
|
|
60
|
+
"""Mutable world, produced by instantiating a WorldSpec. Commit tools
|
|
61
|
+
mutate this in place; read tools only ever read it."""
|
|
62
|
+
|
|
63
|
+
customers: dict[str, Customer]
|
|
64
|
+
orders: dict[str, Order]
|
|
65
|
+
tickets: dict[str, Ticket]
|
|
66
|
+
policies: dict[str, str]
|
|
67
|
+
# Side effects of commit tools that aren't modeled as first-class
|
|
68
|
+
# entities above (replies sent, refunds issued, escalations raised).
|
|
69
|
+
refunds_issued: list[dict] = field(default_factory=list)
|
|
70
|
+
replies_sent: list[dict] = field(default_factory=list)
|
|
71
|
+
escalations: list[dict] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_spec(cls, spec: WorldSpec) -> "WorldState":
|
|
75
|
+
return cls(
|
|
76
|
+
customers=copy.deepcopy(spec.customers),
|
|
77
|
+
orders=copy.deepcopy(spec.orders),
|
|
78
|
+
tickets=copy.deepcopy(spec.tickets),
|
|
79
|
+
policies=copy.deepcopy(spec.policies),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def hash(self) -> str:
|
|
83
|
+
"""Deterministic content hash of the entire world, used to detect
|
|
84
|
+
state-repeat loops (an invariant check) and for test fixtures."""
|
|
85
|
+
|
|
86
|
+
def encode(obj):
|
|
87
|
+
if isinstance(obj, dict):
|
|
88
|
+
return {k: encode(v) for k, v in sorted(obj.items())}
|
|
89
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
90
|
+
return {
|
|
91
|
+
k: encode(getattr(obj, k)) for k in sorted(obj.__dataclass_fields__)
|
|
92
|
+
}
|
|
93
|
+
if isinstance(obj, list):
|
|
94
|
+
return [encode(v) for v in obj]
|
|
95
|
+
return obj
|
|
96
|
+
|
|
97
|
+
payload = json.dumps(encode(self), sort_keys=True, default=str)
|
|
98
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
99
|
+
|
|
100
|
+
def replace_ticket(self, ticket_id: str, **kwargs) -> None:
|
|
101
|
+
self.tickets[ticket_id] = replace(self.tickets[ticket_id], **kwargs)
|
|
102
|
+
|
|
103
|
+
def replace_order(self, order_id: str, **kwargs) -> None:
|
|
104
|
+
self.orders[order_id] = replace(self.orders[order_id], **kwargs)
|
|
105
|
+
|
|
106
|
+
def replace_customer(self, customer_id: str, **kwargs) -> None:
|
|
107
|
+
self.customers[customer_id] = replace(self.customers[customer_id], **kwargs)
|
|
108
|
+
|
|
109
|
+
def append_ticket_note(self, ticket_id: str, note: str) -> None:
|
|
110
|
+
ticket = self.tickets[ticket_id]
|
|
111
|
+
# `notes` is typed tuple[str, ...], but dataclasses.replace() (used
|
|
112
|
+
# by AMBIGUITY's clone_entity_into with model-supplied overrides,
|
|
113
|
+
# which come from JSON and default to list) doesn't enforce that at
|
|
114
|
+
# runtime -- coerce defensively so a prior list-typed notes field
|
|
115
|
+
# doesn't crash the whole run with `list + tuple`.
|
|
116
|
+
self.replace_ticket(ticket_id, notes=tuple(ticket.notes) + (note,))
|
|
117
|
+
|
|
118
|
+
# ---- generic entity protocol ------------------------------------
|
|
119
|
+
# injection.py's apply_injection() and reachability.py's default
|
|
120
|
+
# precondition checker are written against these five methods, not
|
|
121
|
+
# against `self.tickets`/`self.orders`/`self.customers` by name --
|
|
122
|
+
# that's the seam that lets the same injection engine (STALE_READ,
|
|
123
|
+
# AMBIGUITY, CONTRADICTION/LATE_INFO/PROMPT_INJECTION) work against a
|
|
124
|
+
# structurally different domain's own world, as long as that domain's
|
|
125
|
+
# world class implements these same five methods over its own entity
|
|
126
|
+
# types. This is a thin wrapper around the concrete tickets/orders/
|
|
127
|
+
# customers dicts above -- additive, not a replacement, so nothing
|
|
128
|
+
# above changes behavior.
|
|
129
|
+
|
|
130
|
+
def entity_store(self, entity_type: str) -> dict:
|
|
131
|
+
try:
|
|
132
|
+
return getattr(self, _ENTITY_STORES[entity_type])
|
|
133
|
+
except KeyError:
|
|
134
|
+
raise ValueError(f"unknown entity type {entity_type!r}") from None
|
|
135
|
+
|
|
136
|
+
def entity_exists(self, entity_type: str, entity_id: str) -> bool:
|
|
137
|
+
return entity_id in self.entity_store(entity_type)
|
|
138
|
+
|
|
139
|
+
def get_entity(self, entity_type: str, entity_id: str):
|
|
140
|
+
return self.entity_store(entity_type)[entity_id]
|
|
141
|
+
|
|
142
|
+
def set_entity_field(self, entity_type: str, entity_id: str, field_name: str, value) -> None:
|
|
143
|
+
store = self.entity_store(entity_type)
|
|
144
|
+
store[entity_id] = replace(store[entity_id], **{field_name: value})
|
|
145
|
+
|
|
146
|
+
def clone_entity(self, entity_type: str, source_id: str, new_id: str, overrides: dict) -> None:
|
|
147
|
+
clone_entity_into(self, entity_type, source_id, new_id, **overrides)
|
|
148
|
+
|
|
149
|
+
def append_note(self, entity_type: str, entity_id: str, note: str) -> None:
|
|
150
|
+
field_name = _NOTES_FIELD_BY_ENTITY_TYPE.get(entity_type)
|
|
151
|
+
if field_name is None:
|
|
152
|
+
raise ValueError(f"entity type {entity_type!r} has no notes field to append to")
|
|
153
|
+
store = self.entity_store(entity_type)
|
|
154
|
+
entity = store[entity_id]
|
|
155
|
+
current = getattr(entity, field_name)
|
|
156
|
+
# same defensive tuple-coercion as append_ticket_note, for the same reason.
|
|
157
|
+
store[entity_id] = replace(entity, **{field_name: tuple(current) + (note,)})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_ENTITY_STORES = {
|
|
161
|
+
"ticket": "tickets",
|
|
162
|
+
"order": "orders",
|
|
163
|
+
"customer": "customers",
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_NOTES_FIELD_BY_ENTITY_TYPE = {
|
|
167
|
+
"ticket": "notes",
|
|
168
|
+
}
|
|
169
|
+
"""Which entity types have an appendable notes/history field, and what
|
|
170
|
+
it's called. CONTRADICTION/LATE_INFO/PROMPT_INJECTION can only target
|
|
171
|
+
entity types listed here. A different domain's world class defines its
|
|
172
|
+
own mapping for its own entity types -- this one is specific to the
|
|
173
|
+
ticket-support domain."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def clone_entity_into(world: WorldState, entity_type: str, source_id: str, new_id: str, **overrides) -> None:
|
|
177
|
+
"""Clones a ticket/order/customer under a new id, with optional field
|
|
178
|
+
overrides -- how AMBIGUITY injections plant a fresh near-duplicate
|
|
179
|
+
record mid-run. Raises KeyError if source_id or new_id/entity_type is
|
|
180
|
+
invalid; callers apply this to a live WorldState, not a spec.
|
|
181
|
+
"""
|
|
182
|
+
store_name = _ENTITY_STORES[entity_type]
|
|
183
|
+
store = getattr(world, store_name)
|
|
184
|
+
# `new_id` is authoritative -- an "id" key in overrides (e.g. a
|
|
185
|
+
# redundant model-supplied field) would otherwise collide with it as a
|
|
186
|
+
# duplicate replace() keyword argument.
|
|
187
|
+
overrides = {k: v for k, v in overrides.items() if k != "id"}
|
|
188
|
+
clone = replace(store[source_id], id=new_id, **overrides)
|
|
189
|
+
store[new_id] = clone
|