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/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
|
+
)
|