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
@@ -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