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/trajectory.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Per-step and whole-run records produced by the runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, NamedTuple, Optional
|
|
8
|
+
|
|
9
|
+
from agentprobe.injection import AppliedInjection, ExpiredInjection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CallRecord(NamedTuple):
|
|
13
|
+
"""Minimal record of one tool call, as seen by reachable(). Deliberately
|
|
14
|
+
decoupled from Step so the pure checker has no dependency on runner
|
|
15
|
+
bookkeeping (latency, cost, world_hash)."""
|
|
16
|
+
|
|
17
|
+
tool_name: str
|
|
18
|
+
args: dict[str, Any]
|
|
19
|
+
ok: bool
|
|
20
|
+
is_commit: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReachabilityStatus(str, Enum):
|
|
24
|
+
ACHIEVED = "achieved"
|
|
25
|
+
REACHABLE = "reachable"
|
|
26
|
+
UNREACHABLE = "unreachable"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Reachability:
|
|
31
|
+
status: ReachabilityStatus
|
|
32
|
+
reason: Optional[str] = None
|
|
33
|
+
"""Human-readable reason, set when status flips to UNREACHABLE."""
|
|
34
|
+
required_commits_outstanding: int = 0
|
|
35
|
+
required_facts_unknown: int = 0
|
|
36
|
+
forbidden_commits_hit: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def progress(self) -> int:
|
|
40
|
+
return self.required_commits_outstanding + self.required_facts_unknown
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Step:
|
|
45
|
+
index: int
|
|
46
|
+
tool_name: str
|
|
47
|
+
tool_args: dict[str, Any]
|
|
48
|
+
is_commit: bool
|
|
49
|
+
result: Any # tool return value, or {"error": ...} on ToolError/injected error
|
|
50
|
+
ok: bool
|
|
51
|
+
world_hash: str
|
|
52
|
+
reachability: Reachability
|
|
53
|
+
progress: int
|
|
54
|
+
injected_error: bool = False
|
|
55
|
+
"""True if this step's tool call was intercepted by a TOOL_ERROR
|
|
56
|
+
injection rather than actually executed."""
|
|
57
|
+
latency_s: float = 0.0
|
|
58
|
+
cost_usd: float = 0.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Trajectory:
|
|
63
|
+
scenario_id: str
|
|
64
|
+
variant: str = "clean" # "clean" | "chaos" | "recovery"
|
|
65
|
+
steps: list[Step] = field(default_factory=list)
|
|
66
|
+
final_answer: Optional[str] = None
|
|
67
|
+
halted_reason: str = "" # "agent_stopped" | "max_steps" | "max_cost"
|
|
68
|
+
breaking_step: Optional[int] = None
|
|
69
|
+
"""Index of the first step at which reachability became UNREACHABLE,
|
|
70
|
+
or None if the run never became unreachable."""
|
|
71
|
+
final_status: ReachabilityStatus = ReachabilityStatus.REACHABLE
|
|
72
|
+
injections: list[AppliedInjection] = field(default_factory=list)
|
|
73
|
+
"""Injections that actually fired (their trigger matched)."""
|
|
74
|
+
expired_injections: list[ExpiredInjection] = field(default_factory=list)
|
|
75
|
+
"""Injections armed but never fired -- a bad prediction by the
|
|
76
|
+
Injector, distinct from INVALID (which only applies to injections
|
|
77
|
+
that fired and then broke the goal)."""
|
|
78
|
+
injector_errors: list[str] = field(default_factory=list)
|
|
79
|
+
"""Injector decisions that couldn't be applied (bad payload, API
|
|
80
|
+
error) -- never crashes the run, but worth reading (spec section 12:
|
|
81
|
+
injector gaming shows up here too)."""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def passed(self) -> bool:
|
|
85
|
+
return self.final_status == ReachabilityStatus.ACHIEVED
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def total_cost_usd(self) -> float:
|
|
89
|
+
"""Sum of the Target's own per-step API cost -- see Step.cost_usd.
|
|
90
|
+
Injector/classifier calls aren't included here (they aren't
|
|
91
|
+
per-trajectory in the same sense, and a real deployment's Target
|
|
92
|
+
may not even be a Claude call at all -- see agents/base.py)."""
|
|
93
|
+
return sum(s.cost_usd for s in self.steps)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def wasted_steps(self) -> int:
|
|
97
|
+
"""Steps taken after the run was already doomed."""
|
|
98
|
+
if self.breaking_step is None:
|
|
99
|
+
return 0
|
|
100
|
+
return len(self.steps) - (self.breaking_step + 1)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def discarded(self) -> bool:
|
|
104
|
+
"""True if any applied injection was found invalid -- the run
|
|
105
|
+
can't be attributed to the target's competence past that point
|
|
106
|
+
(spec section 8)."""
|
|
107
|
+
return any(not a.valid for a in self.injections)
|
agentprobe/triage.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Failure triage: for one failed Trajectory, render a diff block showing
|
|
2
|
+
what the goal required, what the Target actually did, and whether the
|
|
3
|
+
scenario's own answer key (per validate_scenarios.py) is implicated --
|
|
4
|
+
distinguishing a scenario bug from a real Target failure at the point of
|
|
5
|
+
failure, not just at the whole-scenario level.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from agentprobe.domain import Domain, TICKET_DOMAIN
|
|
11
|
+
from agentprobe.reachability import _find_wrong_arg_commit
|
|
12
|
+
from agentprobe.scenario import CommitPattern, Scenario
|
|
13
|
+
from agentprobe.trajectory import CallRecord, ReachabilityStatus, Step, Trajectory
|
|
14
|
+
from agentprobe.validate_scenarios import Severity, validate_scenario
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _format_call(tool: str, args: dict) -> str:
|
|
18
|
+
return f"{tool}({', '.join(str(v) for v in args.values())})"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _calls_from_steps(steps: list[Step]) -> list[CallRecord]:
|
|
22
|
+
return [CallRecord(s.tool_name, s.tool_args, s.ok, s.is_commit) for s in steps]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _describe_diff(expected_args: dict, actual_args: dict, entity_id_arg: str = "ticket_id", entity_noun: str = "ticket") -> str:
|
|
26
|
+
if expected_args.get(entity_id_arg) != actual_args.get(entity_id_arg):
|
|
27
|
+
return f"wrong {entity_noun}"
|
|
28
|
+
diffs = [k for k in expected_args if k != entity_id_arg and expected_args.get(k) != actual_args.get(k)]
|
|
29
|
+
if diffs:
|
|
30
|
+
return f"correct {entity_noun}, wrong {' & '.join(diffs)}"
|
|
31
|
+
return f"correct {entity_noun}, extra/missing args"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _world_context(scenario: Scenario, tool: str, args: dict) -> str | None:
|
|
35
|
+
"""Ground-truth value relevant to this call, for issue_refund: the
|
|
36
|
+
order's own amount, independent of what any commit pattern claims."""
|
|
37
|
+
if tool != "issue_refund":
|
|
38
|
+
return None
|
|
39
|
+
ticket = scenario.world.tickets.get(args.get("ticket_id"))
|
|
40
|
+
if ticket is None or ticket.order_id is None:
|
|
41
|
+
return None
|
|
42
|
+
order = scenario.world.orders.get(ticket.order_id)
|
|
43
|
+
if order is None:
|
|
44
|
+
return None
|
|
45
|
+
return f"{order.id}.amount = {order.amount}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _goal_context(pattern: CommitPattern | None) -> str | None:
|
|
49
|
+
if pattern is not None and pattern.tool == "issue_refund" and "amount" in pattern.args:
|
|
50
|
+
return str(pattern.args["amount"])
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _matching_required_pattern(
|
|
55
|
+
patterns: list[CommitPattern], tool: str, entity_id: str | None, entity_id_arg: str = "ticket_id"
|
|
56
|
+
) -> CommitPattern | None:
|
|
57
|
+
same_tool = [p for p in patterns if p.tool == tool]
|
|
58
|
+
same_entity = next((p for p in same_tool if p.args.get(entity_id_arg) == entity_id), None)
|
|
59
|
+
return same_entity or (same_tool[0] if same_tool else None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def triage_failure(scenario: Scenario, traj: Trajectory, domain: Domain = TICKET_DOMAIN) -> str:
|
|
63
|
+
"""Renders the diff block for one failed trajectory. Assumes traj is
|
|
64
|
+
already known to have failed (traj.passed is False) -- callers should
|
|
65
|
+
filter to failures before calling this."""
|
|
66
|
+
lines = [f"FAIL {scenario.id}"]
|
|
67
|
+
entity_id_arg = domain.entity_id_arg
|
|
68
|
+
entity_noun = entity_id_arg.removesuffix("_id") or entity_id_arg
|
|
69
|
+
|
|
70
|
+
calls = _calls_from_steps(traj.steps)
|
|
71
|
+
committed_ok = [c for c in calls if c.ok and c.is_commit]
|
|
72
|
+
wrong_arg = _find_wrong_arg_commit(committed_ok, scenario.goal.required_commits, entity_id_arg)
|
|
73
|
+
|
|
74
|
+
if wrong_arg is not None:
|
|
75
|
+
expected_pattern = _matching_required_pattern(
|
|
76
|
+
scenario.goal.required_commits, wrong_arg.tool_name, wrong_arg.args.get(entity_id_arg), entity_id_arg
|
|
77
|
+
)
|
|
78
|
+
expected_str = _format_call(expected_pattern.tool, expected_pattern.args) if expected_pattern else "(no matching required pattern)"
|
|
79
|
+
actual_str = _format_call(wrong_arg.tool_name, wrong_arg.args)
|
|
80
|
+
diff = _describe_diff(expected_pattern.args if expected_pattern else {}, wrong_arg.args, entity_id_arg, entity_noun)
|
|
81
|
+
lines.append(f" expected: {expected_str}")
|
|
82
|
+
lines.append(f" actual: {actual_str}")
|
|
83
|
+
lines.append(f" diff: {diff}")
|
|
84
|
+
world_line = _world_context(scenario, wrong_arg.tool_name, wrong_arg.args)
|
|
85
|
+
if world_line:
|
|
86
|
+
lines.append(f" world: {world_line}")
|
|
87
|
+
goal_line = _goal_context(expected_pattern)
|
|
88
|
+
if goal_line:
|
|
89
|
+
lines.append(f" goal: {goal_line}")
|
|
90
|
+
else:
|
|
91
|
+
forbidden_hit = next(
|
|
92
|
+
(c for c in committed_ok if any(p.matches(c.tool_name, c.args) for p in scenario.goal.forbidden_commits)),
|
|
93
|
+
None,
|
|
94
|
+
)
|
|
95
|
+
if forbidden_hit is not None:
|
|
96
|
+
lines.append(f" actual: {_format_call(forbidden_hit.tool_name, forbidden_hit.args)}")
|
|
97
|
+
lines.append(" diff: forbidden action taken")
|
|
98
|
+
else:
|
|
99
|
+
satisfied_idx = {
|
|
100
|
+
i for i, p in enumerate(scenario.goal.required_commits) if any(p.matches(c.tool_name, c.args) for c in committed_ok)
|
|
101
|
+
}
|
|
102
|
+
outstanding = [p for i, p in enumerate(scenario.goal.required_commits) if i not in satisfied_idx]
|
|
103
|
+
if outstanding:
|
|
104
|
+
lines.append(" never attempted:")
|
|
105
|
+
for p in outstanding:
|
|
106
|
+
lines.append(f" - {_format_call(p.tool, p.args)}")
|
|
107
|
+
reason = None
|
|
108
|
+
if traj.breaking_step is not None and traj.breaking_step < len(traj.steps):
|
|
109
|
+
reason = traj.steps[traj.breaking_step].reachability.reason
|
|
110
|
+
lines.append(f" reason: {reason or f'run ended ({traj.halted_reason}) with the above never attempted'}")
|
|
111
|
+
|
|
112
|
+
# Task-1 check: is the scenario itself implicated, independent of what
|
|
113
|
+
# went wrong above? Only answer-key-correctness checks belong here --
|
|
114
|
+
# no_reconsideration_room is a chaos-mode injection-testability note
|
|
115
|
+
# (can CONTRADICTION/AMBIGUITY ever fire fairly here), completely
|
|
116
|
+
# unrelated to whether THIS trajectory's goal was reachable/achieved.
|
|
117
|
+
# It fires on the majority of scenarios (11/20 in this suite), so
|
|
118
|
+
# surfacing it here would misleadingly imply relevance to failures
|
|
119
|
+
# that have nothing to do with injections at all -- including plain
|
|
120
|
+
# clean-run failures, where there's no injector in the loop to begin
|
|
121
|
+
# with.
|
|
122
|
+
_NOT_A_FAILURE_EXPLANATION = {"no_reconsideration_room"}
|
|
123
|
+
report = validate_scenario(scenario, domain)
|
|
124
|
+
for issue in report.issues:
|
|
125
|
+
if issue.check in _NOT_A_FAILURE_EXPLANATION:
|
|
126
|
+
continue
|
|
127
|
+
if issue.severity == Severity.BROKEN:
|
|
128
|
+
lines.append(f" ⚠ {issue.check}: {issue.message} -- SCENARIO BUG, not agent failure")
|
|
129
|
+
elif issue.severity == Severity.WARN:
|
|
130
|
+
lines.append(f" ⚠ {issue.check}: {issue.message} -- possible scenario issue, verify by hand")
|
|
131
|
+
|
|
132
|
+
# last 5 steps before the point of failure
|
|
133
|
+
end = traj.breaking_step if traj.breaking_step is not None else len(traj.steps) - 1
|
|
134
|
+
start = max(0, end - 4)
|
|
135
|
+
if traj.steps:
|
|
136
|
+
lines.append(" last steps before failure:")
|
|
137
|
+
for s in traj.steps[start : end + 1]:
|
|
138
|
+
outcome = "ok" if s.ok else f"ERROR: {s.result}"
|
|
139
|
+
lines.append(f" step {s.index}: {s.tool_name}({s.tool_args}) -> {outcome}")
|
|
140
|
+
else:
|
|
141
|
+
lines.append(" last steps before failure: (none -- zero tool calls made)")
|
|
142
|
+
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def triage_all_failures(scenarios: list[Scenario], trajectories: list[Trajectory], domain: Domain = TICKET_DOMAIN) -> str:
|
|
147
|
+
by_id = {s.id: s for s in scenarios}
|
|
148
|
+
blocks = [
|
|
149
|
+
triage_failure(by_id[t.scenario_id], t, domain)
|
|
150
|
+
for t in trajectories
|
|
151
|
+
if not t.passed and t.scenario_id in by_id
|
|
152
|
+
]
|
|
153
|
+
return "\n\n".join(blocks)
|