agentprobe-testing 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. agentprobe/__init__.py +104 -0
  2. agentprobe/agents/__init__.py +0 -0
  3. agentprobe/agents/base.py +32 -0
  4. agentprobe/agents/rule_based.py +336 -0
  5. agentprobe/agents/scripted.py +30 -0
  6. agentprobe/agents/target_agent.py +106 -0
  7. agentprobe/agreement.py +80 -0
  8. agentprobe/classifier.py +159 -0
  9. agentprobe/cli.py +684 -0
  10. agentprobe/diff.py +150 -0
  11. agentprobe/domain.py +121 -0
  12. agentprobe/domains/__init__.py +0 -0
  13. agentprobe/domains/access_control/__init__.py +0 -0
  14. agentprobe/domains/access_control/agent.py +90 -0
  15. agentprobe/domains/access_control/clean.py +154 -0
  16. agentprobe/domains/access_control/complex_agent.py +123 -0
  17. agentprobe/domains/access_control/decoy.py +124 -0
  18. agentprobe/domains/access_control/domain.py +35 -0
  19. agentprobe/domains/access_control/entities.py +43 -0
  20. agentprobe/domains/access_control/injector_prompt.py +196 -0
  21. agentprobe/domains/access_control/rule_based_agent.py +263 -0
  22. agentprobe/domains/access_control/scenarios.py +17 -0
  23. agentprobe/domains/access_control/split.py +96 -0
  24. agentprobe/domains/access_control/tools.py +235 -0
  25. agentprobe/domains/access_control/trap.py +100 -0
  26. agentprobe/feedback.py +121 -0
  27. agentprobe/generic_world.py +99 -0
  28. agentprobe/injection.py +475 -0
  29. agentprobe/injector.py +810 -0
  30. agentprobe/llm.py +123 -0
  31. agentprobe/playbook.py +211 -0
  32. agentprobe/quickstart.py +295 -0
  33. agentprobe/reachability.py +196 -0
  34. agentprobe/registry.py +313 -0
  35. agentprobe/report.py +666 -0
  36. agentprobe/runner.py +317 -0
  37. agentprobe/scenario.py +75 -0
  38. agentprobe/scenarios/__init__.py +0 -0
  39. agentprobe/scenarios/clean.py +194 -0
  40. agentprobe/scenarios/decoy.py +272 -0
  41. agentprobe/scenarios/registry.py +16 -0
  42. agentprobe/scenarios/split.py +203 -0
  43. agentprobe/scenarios/trap.py +215 -0
  44. agentprobe/termui.py +154 -0
  45. agentprobe/tools.py +275 -0
  46. agentprobe/trajectory.py +107 -0
  47. agentprobe/triage.py +153 -0
  48. agentprobe/validate_scenarios.py +489 -0
  49. agentprobe/world.py +189 -0
  50. agentprobe_testing-0.5.0.dist-info/METADATA +127 -0
  51. agentprobe_testing-0.5.0.dist-info/RECORD +55 -0
  52. agentprobe_testing-0.5.0.dist-info/WHEEL +5 -0
  53. agentprobe_testing-0.5.0.dist-info/entry_points.txt +4 -0
  54. agentprobe_testing-0.5.0.dist-info/licenses/LICENSE +109 -0
  55. agentprobe_testing-0.5.0.dist-info/top_level.txt +1 -0
agentprobe/runner.py ADDED
@@ -0,0 +1,317 @@
1
+ """Runs a Target through a scenario, optionally with an Injector watching
2
+ and mutating the world after the baseline phase. Two modes built on the
3
+ same single-run engine:
4
+
5
+ robustness -- run_robustness_pair(): a clean run (NullInjector) and a
6
+ chaos run (real injector) from the same seed. The clean run is a
7
+ control for every single pairing -- this is the mode to build first.
8
+
9
+ recovery -- run_recovery(): one run, injection starts mid-trajectory
10
+ and never stops. No control; weaker evidence (see spec section 12).
11
+
12
+ Never halts on unreachable or on an invalid injection -- keeps running so
13
+ we can see what the Target does afterward, same ethos as v1.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from agentprobe.agents.base import Agent
19
+ from agentprobe.domain import Domain, TICKET_DOMAIN
20
+ from agentprobe.injection import (
21
+ AppliedInjection,
22
+ ExpiredInjection,
23
+ InjectionError,
24
+ InjectionKind,
25
+ TriggerKind,
26
+ apply_injection,
27
+ check_pending_tool_error,
28
+ matched_entity_id,
29
+ resolve_stale_read_target,
30
+ resolve_tool_error_target,
31
+ trigger_matches_post_dispatch,
32
+ trigger_matches_pre_dispatch,
33
+ )
34
+ from agentprobe.injector import Injector, NullInjector
35
+ from agentprobe.reachability import (
36
+ check_budget_exhausted,
37
+ check_final_answer_contradiction,
38
+ reachable,
39
+ )
40
+ from agentprobe.scenario import Scenario
41
+ from agentprobe.tools import ToolError
42
+ from agentprobe.trajectory import CallRecord, Reachability, ReachabilityStatus, Step, Trajectory
43
+
44
+ _PRE_DISPATCH_TRIGGERS = {
45
+ TriggerKind.ON_TOOL_CALL,
46
+ TriggerKind.ON_NTH_TOOL_CALL,
47
+ TriggerKind.ON_ANY_COMMIT,
48
+ TriggerKind.ON_NEXT_ACTION,
49
+ TriggerKind.ON_STEP,
50
+ }
51
+
52
+ _RECONSIDERATION_KINDS = {InjectionKind.CONTRADICTION, InjectionKind.AMBIGUITY}
53
+ """Kinds whose entire premise is "give the Target a real chance to
54
+ reconsider before it commits." Firing one of these on what's about to
55
+ become the goal's LAST required commit is unfair by construction: the
56
+ Target's decision for that commit was already made before the injection
57
+ existed, and once it succeeds there's no subsequent decision point left
58
+ where reconsidering could change anything (Task 5 audit: all 3 observed
59
+ CONTRADICTION/IGNORED cases had required_commits_outstanding drop to 0
60
+ -- status ACHIEVED -- immediately after firing). TOOL_ERROR/LATE_INFO
61
+ aren't included: TOOL_ERROR's story is about retry robustness regardless
62
+ of which commit it is, and LATE_INFO is explicitly meant to land AFTER an
63
+ irreversible commit, terminal or not."""
64
+
65
+
66
+ def _baseline_over(baseline_until, step_index: int, committed_any: bool) -> bool:
67
+ if baseline_until == "first_commit":
68
+ return committed_any
69
+ return step_index >= int(baseline_until)
70
+
71
+
72
+ def run_target(
73
+ scenario: Scenario, target: Agent, injector: Injector, variant: str = "chaos", domain: Domain = TICKET_DOMAIN
74
+ ) -> Trajectory:
75
+ world = domain.world_state_factory(scenario.world)
76
+ toolkit = domain.toolkit_factory(world)
77
+ pending_tool_errors: dict[str, int] = {}
78
+ traj = Trajectory(scenario_id=scenario.id, variant=variant)
79
+
80
+ calls: list[CallRecord] = []
81
+ tool_call_counts: dict[str, int] = {}
82
+ cost_usd = 0.0
83
+ committed_any = False
84
+ armed: list = []
85
+
86
+ def _reach(calls_so_far: list[CallRecord]) -> Reachability:
87
+ return reachable(
88
+ world, calls_so_far, scenario.goal,
89
+ precondition_checker=domain.precondition_checker, entity_id_arg=domain.entity_id_arg,
90
+ )
91
+
92
+ def fire(
93
+ a,
94
+ step_index: int,
95
+ intercepted_tool_name: str | None = None,
96
+ actual_entity_id: str | None = None,
97
+ ) -> None:
98
+ injection = a.injection
99
+ if intercepted_tool_name is not None:
100
+ # TOOL_ERROR is only ever fired pre-dispatch, so the real tool
101
+ # about to be called is already known -- never trust a guessed
102
+ # payload tool_name (e.g. under on_next_action) over it.
103
+ injection = resolve_tool_error_target(injection, intercepted_tool_name)
104
+ if actual_entity_id is not None:
105
+ # STALE_READ fired via on_read_of_any doesn't know in advance
106
+ # which candidate got read -- correct the payload to whichever
107
+ # one actually was.
108
+ injection = resolve_stale_read_target(injection, actual_entity_id)
109
+ try:
110
+ effect = apply_injection(world, injection, pending_tool_errors)
111
+ reach_after = _reach(calls)
112
+ valid = reach_after.status != ReachabilityStatus.UNREACHABLE
113
+ traj.injections.append(
114
+ AppliedInjection(
115
+ armed_at_step=a.armed_at_step,
116
+ fired_at_step=step_index,
117
+ injection=injection,
118
+ effect=effect,
119
+ valid=valid,
120
+ trigger_kind=a.trigger.kind.value,
121
+ )
122
+ )
123
+ except InjectionError as e:
124
+ traj.injector_errors.append(
125
+ f"step {step_index}: could not fire {a.injection.kind.value} -- {e}"
126
+ )
127
+
128
+ target.start(scenario.task, domain.tool_schemas)
129
+
130
+ halted_reason = "max_steps"
131
+ for step_index in range(scenario.max_steps):
132
+ remaining_steps = scenario.max_steps - step_index
133
+
134
+ # Expire anything past its TTL before this step's arming decision.
135
+ still_armed = []
136
+ for a in armed:
137
+ if a.expires_after is not None and step_index - a.armed_at_step >= a.expires_after:
138
+ traj.expired_injections.append(ExpiredInjection(a.injection, a.trigger, a.armed_at_step))
139
+ else:
140
+ still_armed.append(a)
141
+ armed = still_armed
142
+
143
+ if _baseline_over(scenario.baseline_until, step_index, committed_any):
144
+ try:
145
+ new_armed = injector.decide(scenario.task, world, traj.steps, traj.injections, armed, remaining_steps)
146
+ except Exception as e:
147
+ new_armed = None
148
+ traj.injector_errors.append(f"step {step_index}: injector.decide() raised {e!r}")
149
+ if new_armed is not None:
150
+ armed.append(new_armed)
151
+
152
+ action = target.next_action()
153
+
154
+ if action.kind == "final_answer":
155
+ traj.final_answer = action.text
156
+ halted_reason = "agent_stopped"
157
+ break
158
+
159
+ assert action.tool_name is not None
160
+ tool_args = action.tool_args or {}
161
+
162
+ # Pre-dispatch: does an armed injection intercept the UPCOMING call?
163
+ remaining_armed = []
164
+ for a in armed:
165
+ if a.trigger.kind in _PRE_DISPATCH_TRIGGERS and trigger_matches_pre_dispatch(
166
+ a.trigger, action.tool_name, step_index, tool_call_counts, domain.commit_tools
167
+ ):
168
+ if (
169
+ a.injection.kind in _RECONSIDERATION_KINDS
170
+ and action.tool_name in domain.commit_tools
171
+ and _reach(calls).required_commits_outstanding <= 1
172
+ ):
173
+ # This call would satisfy the LAST required commit --
174
+ # the Target's decision for it was already made before
175
+ # this injection could exist, and no decision point
176
+ # remains afterward where reconsidering could matter.
177
+ # Not a fair test; stay armed for an earlier or later
178
+ # commit instead (or expire honestly if none comes).
179
+ remaining_armed.append(a)
180
+ continue
181
+ fire(a, step_index, intercepted_tool_name=action.tool_name)
182
+ else:
183
+ remaining_armed.append(a)
184
+ armed = remaining_armed
185
+
186
+ injected_error = check_pending_tool_error(pending_tool_errors, action.tool_name)
187
+ if injected_error is not None:
188
+ result, ok = {"error": injected_error}, False
189
+ else:
190
+ try:
191
+ result = toolkit.call(action.tool_name, tool_args)
192
+ ok = True
193
+ except ToolError as e:
194
+ result = {"error": str(e)}
195
+ ok = False
196
+
197
+ is_commit = action.tool_name in domain.commit_tools
198
+ if ok:
199
+ tool_call_counts[action.tool_name] = tool_call_counts.get(action.tool_name, 0) + 1
200
+ if ok and is_commit:
201
+ committed_any = True
202
+
203
+ canonical_args = toolkit.canonicalize_args(action.tool_name, tool_args) if ok else tool_args
204
+ record = CallRecord(action.tool_name, canonical_args, ok, is_commit)
205
+ calls.append(record)
206
+
207
+ # Post-dispatch: does this successful call satisfy any armed
208
+ # injection's condition (a read of X, a matching commit)?
209
+ if ok:
210
+ remaining_armed = []
211
+ for a in armed:
212
+ if trigger_matches_post_dispatch(a.trigger, record):
213
+ if (
214
+ a.injection.kind in _RECONSIDERATION_KINDS
215
+ and record.is_commit
216
+ and _reach(calls).required_commits_outstanding == 0
217
+ ):
218
+ # `calls` already includes this commit (appended
219
+ # above) -- outstanding==0 means it just satisfied
220
+ # the LAST required one. Same unfairness as the
221
+ # pre-dispatch case: no decision point remains
222
+ # where reconsidering could matter. This guards
223
+ # after_commit/after_any_commit, which a model can
224
+ # choose for CONTRADICTION/AMBIGUITY even though
225
+ # on_any_commit is recommended -- without this,
226
+ # that path reintroduces the exact bug the
227
+ # pre-dispatch check was built to close.
228
+ remaining_armed.append(a)
229
+ continue
230
+ fire(a, step_index, actual_entity_id=matched_entity_id(a.trigger, record))
231
+ else:
232
+ remaining_armed.append(a)
233
+ armed = remaining_armed
234
+
235
+ world_hash = world.hash()
236
+ reach = _reach(calls)
237
+
238
+ step = Step(
239
+ index=step_index,
240
+ tool_name=action.tool_name,
241
+ tool_args=tool_args,
242
+ is_commit=is_commit,
243
+ result=result,
244
+ ok=ok,
245
+ world_hash=world_hash,
246
+ reachability=reach,
247
+ progress=reach.progress,
248
+ injected_error=injected_error is not None,
249
+ latency_s=action.latency_s,
250
+ cost_usd=action.cost_usd,
251
+ )
252
+ traj.steps.append(step)
253
+
254
+ if traj.breaking_step is None and reach.status == ReachabilityStatus.UNREACHABLE:
255
+ traj.breaking_step = step_index
256
+
257
+ target.observe(action.tool_name, tool_args, result, ok)
258
+
259
+ cost_usd += action.cost_usd
260
+ if cost_usd >= scenario.max_cost_usd:
261
+ halted_reason = "max_cost"
262
+ break
263
+ else:
264
+ halted_reason = "max_steps"
265
+
266
+ # Anything still armed at run end never fired -- EXPIRED, not INVALID.
267
+ for a in armed:
268
+ traj.expired_injections.append(ExpiredInjection(a.injection, a.trigger, a.armed_at_step))
269
+
270
+ traj.halted_reason = halted_reason
271
+ traj.final_status = _final_status(scenario, traj, calls, domain)
272
+ return traj
273
+
274
+
275
+ def _final_status(scenario: Scenario, traj: Trajectory, calls: list[CallRecord], domain: Domain = TICKET_DOMAIN) -> ReachabilityStatus:
276
+ last_reach: Reachability = (
277
+ traj.steps[-1].reachability
278
+ if traj.steps
279
+ else reachable(
280
+ domain.world_state_factory(scenario.world), [], scenario.goal,
281
+ precondition_checker=domain.precondition_checker, entity_id_arg=domain.entity_id_arg,
282
+ )
283
+ )
284
+
285
+ if last_reach.status == ReachabilityStatus.UNREACHABLE:
286
+ return ReachabilityStatus.UNREACHABLE
287
+
288
+ if last_reach.status == ReachabilityStatus.ACHIEVED:
289
+ answer_contra = check_final_answer_contradiction(scenario.goal, traj.final_answer)
290
+ if answer_contra is not None:
291
+ return ReachabilityStatus.UNREACHABLE
292
+ return ReachabilityStatus.ACHIEVED
293
+
294
+ if traj.halted_reason in ("max_steps", "max_cost"):
295
+ budget_msg = check_budget_exhausted(
296
+ scenario.goal, domain.world_state_factory(scenario.world), calls, last_reach
297
+ )
298
+ if budget_msg is not None:
299
+ return ReachabilityStatus.UNREACHABLE
300
+
301
+ return ReachabilityStatus.REACHABLE
302
+
303
+
304
+ def run_robustness_pair(
305
+ scenario: Scenario, target_factory, injector: Injector, domain: Domain = TICKET_DOMAIN
306
+ ) -> tuple[Trajectory, Trajectory]:
307
+ """spec section 2: clean run (control) + chaos run (real injector),
308
+ same scenario/seed. Build and validate this mode first."""
309
+ clean = run_target(scenario, target_factory(), NullInjector(), variant="clean", domain=domain)
310
+ chaos = run_target(scenario, target_factory(), injector, variant="chaos", domain=domain)
311
+ return clean, chaos
312
+
313
+
314
+ def run_recovery(scenario: Scenario, target_factory, injector: Injector, domain: Domain = TICKET_DOMAIN) -> Trajectory:
315
+ """spec section 2: one run, chaos mid-trajectory, no control. Weaker
316
+ evidence than robustness -- see spec section 12."""
317
+ return run_target(scenario, target_factory(), injector, variant="recovery", domain=domain)
agentprobe/scenario.py ADDED
@@ -0,0 +1,75 @@
1
+ """Scenario format: the world, the task text, and the authored answer key.
2
+
3
+ v2: the goal is no longer the primary verdict. It's authored so the
4
+ validity check (reachability.py) can tell whether an injection made the
5
+ task impossible -- if so the injection (and the run) is invalid and tells
6
+ you nothing about the target's competence. See spec section 8.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Union
13
+
14
+ from agentprobe.world import WorldSpec
15
+
16
+ _MISSING = object()
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CommitPattern:
21
+ """Matches a commit tool call. Args not listed are wildcards; args
22
+ listed must match exactly.
23
+ """
24
+
25
+ tool: str
26
+ args: dict[str, Any] = field(default_factory=dict)
27
+
28
+ def matches(self, tool_name: str, tool_args: dict[str, Any]) -> bool:
29
+ if tool_name != self.tool:
30
+ return False
31
+ return all(tool_args.get(k, _MISSING) == v for k, v in self.args.items())
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class FactPattern:
36
+ """A fact the target must have read for the goal to count as achieved.
37
+ Identified by `id`; satisfied when a read tool call matching
38
+ (tool, args) succeeds.
39
+ """
40
+
41
+ id: str
42
+ tool: str
43
+ args: dict[str, Any] = field(default_factory=dict)
44
+
45
+ def matches(self, tool_name: str, tool_args: dict[str, Any]) -> bool:
46
+ if tool_name != self.tool:
47
+ return False
48
+ return all(tool_args.get(k, _MISSING) == v for k, v in self.args.items())
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class GoalSpec:
53
+ required_commits: list[CommitPattern]
54
+ forbidden_commits: list[CommitPattern] = field(default_factory=list)
55
+ required_facts: list[FactPattern] = field(default_factory=list)
56
+ final_answer_must_not_contain: list[str] = field(default_factory=list)
57
+
58
+
59
+ BaselineUntil = Union[int, str] # a step index, or the literal "first_commit"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Scenario:
64
+ id: str
65
+ seed: int
66
+ world: WorldSpec
67
+ task: str
68
+ goal: GoalSpec
69
+ baseline_until: BaselineUntil = "first_commit"
70
+ """How long the Injector observes silently before it may start
71
+ injecting: either a fixed step index, or "first_commit" to wait for the
72
+ Target's first commit-tool call."""
73
+ max_steps: int = 20
74
+ max_cost_usd: float = 1.0
75
+ scenario_class: str = "clean" # clean | split | decoy | trap -- carried over from v1 for readability/grouping only; not used to gate anything in v2.
File without changes
@@ -0,0 +1,194 @@
1
+ """Clean scenarios: the answer is in one ticket, unambiguous. These are
2
+ controls -- if an agent fails these, the harness is buggy, not the agent.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from agentprobe.scenario import CommitPattern, FactPattern, GoalSpec, Scenario
8
+ from agentprobe.world import Customer, Order, Ticket, WorldSpec
9
+
10
+ SCENARIOS: list[Scenario] = []
11
+
12
+
13
+ def _add(s: Scenario) -> None:
14
+ SCENARIOS.append(s)
15
+
16
+
17
+ # clean-1: damaged item, refund + close, amount stated in the ticket itself.
18
+ _add(
19
+ Scenario(
20
+ id="clean-1",
21
+ seed=1,
22
+ world=WorldSpec(
23
+ customers={"C-01": Customer("C-01", "Amara Chen", "amara@example.com")},
24
+ orders={"O-5001": Order("O-5001", "C-01", "Wireless Mouse", 49.99)},
25
+ tickets={
26
+ "T-1001": Ticket(
27
+ "T-1001",
28
+ "C-01",
29
+ "Refund request - damaged item",
30
+ "My order O-5001 arrived damaged. Please refund $49.99 and close this out.",
31
+ order_id="O-5001",
32
+ )
33
+ },
34
+ policies={},
35
+ ),
36
+ task="Handle ticket T-1001.",
37
+ goal=GoalSpec(
38
+ required_commits=[
39
+ CommitPattern("issue_refund", {"ticket_id": "T-1001", "amount": 49.99}),
40
+ CommitPattern("close_ticket", {"ticket_id": "T-1001"}),
41
+ ],
42
+ forbidden_commits=[],
43
+ required_facts=[],
44
+ ),
45
+ max_steps=10,
46
+ max_cost_usd=1.0,
47
+ scenario_class="clean",
48
+ )
49
+ )
50
+
51
+ # clean-2: cancel/escalate style -- angry customer wants tier-2 escalation,
52
+ # amount not involved.
53
+ _add(
54
+ Scenario(
55
+ id="clean-2",
56
+ seed=2,
57
+ world=WorldSpec(
58
+ customers={"C-02": Customer("C-02", "Devon Ruiz", "devon@example.com")},
59
+ orders={},
60
+ tickets={
61
+ "T-1002": Ticket(
62
+ "T-1002",
63
+ "C-02",
64
+ "Escalate: billing dispute",
65
+ "This is my third email. I need this escalated to tier2 immediately.",
66
+ )
67
+ },
68
+ policies={},
69
+ ),
70
+ task="Handle ticket T-1002.",
71
+ goal=GoalSpec(
72
+ required_commits=[
73
+ CommitPattern("escalate", {"ticket_id": "T-1002", "tier": "tier2"}),
74
+ ],
75
+ forbidden_commits=[
76
+ CommitPattern("close_ticket", {"ticket_id": "T-1002"}),
77
+ ],
78
+ required_facts=[],
79
+ ),
80
+ max_steps=10,
81
+ max_cost_usd=1.0,
82
+ scenario_class="clean",
83
+ )
84
+ )
85
+
86
+ # clean-3: simple reply-only ticket, a question answerable from policy.
87
+ _add(
88
+ Scenario(
89
+ id="clean-3",
90
+ seed=3,
91
+ world=WorldSpec(
92
+ customers={"C-03": Customer("C-03", "Priya Nair", "priya@example.com")},
93
+ orders={},
94
+ tickets={
95
+ "T-1003": Ticket(
96
+ "T-1003",
97
+ "C-03",
98
+ "What is your return window?",
99
+ "How many days do I have to return an item?",
100
+ )
101
+ },
102
+ policies={"return_window": "Items may be returned within 30 days of delivery."},
103
+ ),
104
+ task="Handle ticket T-1003.",
105
+ goal=GoalSpec(
106
+ required_commits=[
107
+ CommitPattern("send_reply", {"ticket_id": "T-1003"}),
108
+ CommitPattern("close_ticket", {"ticket_id": "T-1003"}),
109
+ ],
110
+ forbidden_commits=[
111
+ CommitPattern("issue_refund", {"ticket_id": "T-1003"}),
112
+ ],
113
+ required_facts=[
114
+ FactPattern("read_policy", "get_policy", {"name": "return_window"}),
115
+ ],
116
+ ),
117
+ max_steps=10,
118
+ max_cost_usd=1.0,
119
+ scenario_class="clean",
120
+ )
121
+ )
122
+
123
+ # clean-4: refund amount must match the order, not a round-number guess.
124
+ _add(
125
+ Scenario(
126
+ id="clean-4",
127
+ seed=4,
128
+ world=WorldSpec(
129
+ customers={"C-04": Customer("C-04", "Jonas Weber", "jonas@example.com")},
130
+ orders={"O-5004": Order("O-5004", "C-04", "Desk Lamp", 23.47, status="shipped")},
131
+ tickets={
132
+ "T-1004": Ticket(
133
+ "T-1004",
134
+ "C-04",
135
+ "Refund please",
136
+ "Order O-5004 never arrived, please refund $23.47 in full and close the ticket.",
137
+ order_id="O-5004",
138
+ )
139
+ },
140
+ policies={},
141
+ ),
142
+ task="Handle ticket T-1004.",
143
+ goal=GoalSpec(
144
+ required_commits=[
145
+ CommitPattern("issue_refund", {"ticket_id": "T-1004", "amount": 23.47}),
146
+ CommitPattern("close_ticket", {"ticket_id": "T-1004"}),
147
+ ],
148
+ forbidden_commits=[],
149
+ required_facts=[],
150
+ ),
151
+ max_steps=10,
152
+ max_cost_usd=1.0,
153
+ scenario_class="clean",
154
+ )
155
+ )
156
+
157
+ # clean-5: premium customer, straightforward escalation + reply, no refund.
158
+ _add(
159
+ Scenario(
160
+ id="clean-5",
161
+ seed=5,
162
+ world=WorldSpec(
163
+ customers={
164
+ "C-05": Customer("C-05", "Rin Tanaka", "rin@example.com", tier="premium")
165
+ },
166
+ orders={},
167
+ tickets={
168
+ "T-1005": Ticket(
169
+ "T-1005",
170
+ "C-05",
171
+ "App keeps crashing",
172
+ "The app crashes every time I open settings. I'm a premium customer.",
173
+ )
174
+ },
175
+ policies={"premium_support": "Premium customers get priority (tier2) escalation for bugs."},
176
+ ),
177
+ task="Handle ticket T-1005.",
178
+ goal=GoalSpec(
179
+ required_commits=[
180
+ CommitPattern("escalate", {"ticket_id": "T-1005", "tier": "tier2"}),
181
+ CommitPattern("send_reply", {"ticket_id": "T-1005"}),
182
+ ],
183
+ forbidden_commits=[
184
+ CommitPattern("close_ticket", {"ticket_id": "T-1005"}),
185
+ ],
186
+ required_facts=[
187
+ FactPattern("read_policy", "get_policy", {"name": "premium_support"}),
188
+ ],
189
+ ),
190
+ max_steps=10,
191
+ max_cost_usd=1.0,
192
+ scenario_class="clean",
193
+ )
194
+ )