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,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)