agentprobe-testing 1.6.0__tar.gz → 1.7.0__tar.gz
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_testing-1.6.0 → agentprobe_testing-1.7.0}/PKG-INFO +1 -1
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/__init__.py +5 -1
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/quickstart.py +24 -1
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/report.py +85 -1
- agentprobe_testing-1.7.0/agentprobe/toolcheck.py +86 -0
- agentprobe_testing-1.7.0/agentprobe/toolimport.py +141 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/PKG-INFO +1 -1
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/SOURCES.txt +4 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/pyproject.toml +1 -1
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_injection_mode.py +2 -2
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_runner_timeouts.py +6 -0
- agentprobe_testing-1.7.0/tests/test_toolcheck.py +114 -0
- agentprobe_testing-1.7.0/tests/test_toolimport.py +47 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/LICENSE +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/README.md +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/base.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/browser_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/rule_based.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/scripted.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agents/target_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/agreement.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/classifier.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/cli.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/diff.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/clean.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/complex_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/decoy.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/entities.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/injector_prompt.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/rule_based_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/split.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/trap.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/chat_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/injector_prompt.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/loader.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/openai_compat_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/oracle.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/user_sim.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/world.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/clean.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/entities.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/injector_prompt.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/rule_based_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/trap.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/feedback.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/fixes.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/generic_world.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/injection.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/injector.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/llm.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/playbook.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/priority.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/reachability.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/registry.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/runner.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/savings.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenario.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/__init__.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/clean.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/decoy.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/registry.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/split.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/scenarios/trap.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/summarize.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/termui.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/trajectory.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/triage.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/validate_scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/world.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/dependency_links.txt +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/entry_points.txt +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/requires.txt +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/top_level.txt +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/setup.cfg +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_rule_based_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_agreement.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_browser_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_classifier.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_cli.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_complex_access_control_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_diff.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_failures.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_feedback.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_fixes.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_generic_world.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_injection.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_injector.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_llm.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_package_api.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_playbook.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_priority.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_quickstart.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_reachability.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_registry.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_report.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_rule_based_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_runner.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_savings.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_target_agent.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_tau_retail.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_termui.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_tools.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_trajectory.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_triage.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_validate_scenarios.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_web_browsing_domain.py +0 -0
- {agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_world.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentprobe-testing
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: Adaptive chaos-testing for LLM agents: a live model-driven Injector that reads an agent's real trajectory and decides where to break something, instead of scripting perturbations in advance.
|
|
5
5
|
License: Business Source License 1.1
|
|
6
6
|
|
|
@@ -57,13 +57,17 @@ from agentprobe.registry import (
|
|
|
57
57
|
from agentprobe.report import InjectionRecord, Report
|
|
58
58
|
from agentprobe.runner import run_recovery, run_robustness_pair
|
|
59
59
|
from agentprobe.savings import FixedFaultInjector, estimate_cost, fixed_fault_for, model_injector_for, retest_failures
|
|
60
|
+
from agentprobe.toolcheck import check_agent_tools
|
|
61
|
+
from agentprobe.toolimport import normalize_tools
|
|
60
62
|
from agentprobe.scenario import CommitPattern, FactPattern, GoalSpec, Scenario
|
|
61
63
|
from agentprobe.scenarios.registry import ALL_SCENARIOS as TICKET_SCENARIOS
|
|
62
64
|
from agentprobe.scenarios.registry import BY_ID as TICKET_SCENARIOS_BY_ID
|
|
63
65
|
|
|
64
|
-
__version__ = "1.
|
|
66
|
+
__version__ = "1.7.0"
|
|
65
67
|
|
|
66
68
|
__all__ = [
|
|
69
|
+
"check_agent_tools",
|
|
70
|
+
"normalize_tools",
|
|
67
71
|
"FixedFaultInjector",
|
|
68
72
|
"estimate_cost",
|
|
69
73
|
"fixed_fault_for",
|
|
@@ -22,6 +22,7 @@ from agentprobe.priority import clean_overrides
|
|
|
22
22
|
from agentprobe.report import InjectionRecord, Report
|
|
23
23
|
from agentprobe.runner import run_recovery, run_robustness_pair, run_target
|
|
24
24
|
from agentprobe.scenario import Scenario
|
|
25
|
+
from agentprobe.toolcheck import check_agent_tools, required_tools
|
|
25
26
|
|
|
26
27
|
DecideFn = Callable[[str, list[dict[str, Any]]], AgentAction]
|
|
27
28
|
|
|
@@ -219,6 +220,7 @@ def quick_test(
|
|
|
219
220
|
injector_cost_usd=_injector_cost_usd(injector),
|
|
220
221
|
priority_overrides=clean_overrides(priority_overrides),
|
|
221
222
|
injection_mode=injection_mode,
|
|
223
|
+
tool_expectations={"known": sorted(domain.all_tools), "required": {scenario.id: sorted(required_tools(scenario))}},
|
|
222
224
|
)
|
|
223
225
|
if recording is not None:
|
|
224
226
|
# Stashed here (not a Report field -- Report is a general-purpose
|
|
@@ -259,6 +261,7 @@ def quick_test_all(
|
|
|
259
261
|
skip_injection_if_clean_fails: bool = False,
|
|
260
262
|
stop_when_achieved: bool = False,
|
|
261
263
|
preflight: bool = False,
|
|
264
|
+
agent_tools: Optional[list[str]] = None,
|
|
262
265
|
) -> Report:
|
|
263
266
|
"""Run every scenario in `scenarios` (a dict[str, Scenario] -- e.g.
|
|
264
267
|
TICKET_SCENARIOS or a FetchedDomain's .scenarios -- or a plain list of
|
|
@@ -305,6 +308,9 @@ def quick_test_all(
|
|
|
305
308
|
agent's final claim) is not tested.
|
|
306
309
|
preflight -- validate every scenario first (free, no model calls) and
|
|
307
310
|
raise ValueError if any is BROKEN, before spending anything.
|
|
311
|
+
agent_tools -- the tool names your agent really has. If given, a scenario that
|
|
312
|
+
needs a tool outside this list raises ValueError before anything is spent: a failure
|
|
313
|
+
aimed at a tool the agent doesn't have can never fire, and the run would look clean.
|
|
308
314
|
|
|
309
315
|
`injector_factory` may take the Scenario as its one argument, e.g.
|
|
310
316
|
`lambda sc: fixed_fault_for(sc)`, to aim an injector per scenario.
|
|
@@ -324,6 +330,13 @@ def quick_test_all(
|
|
|
324
330
|
if broken:
|
|
325
331
|
raise ValueError("preflight found broken scenarios (nothing was run, nothing was spent):\n " + "\n ".join(broken))
|
|
326
332
|
|
|
333
|
+
if agent_tools is not None:
|
|
334
|
+
problems = check_agent_tools([sc for _id, sc in items], agent_tools)
|
|
335
|
+
if problems:
|
|
336
|
+
raise ValueError(
|
|
337
|
+
"the tests need tools your agent does not have (nothing was run, nothing was spent):\n " + "\n ".join(problems)
|
|
338
|
+
)
|
|
339
|
+
|
|
327
340
|
target_factory = agent if _looks_like_factory(agent) else wrap_agent(agent)
|
|
328
341
|
raw_make_injector = injector_factory if injector_factory is not None else (lambda: NullInjector())
|
|
329
342
|
if not callable(raw_make_injector):
|
|
@@ -428,6 +441,10 @@ def quick_test_all(
|
|
|
428
441
|
injector_cost_usd=total_injector_cost,
|
|
429
442
|
priority_overrides=clean_overrides(priority_overrides),
|
|
430
443
|
injection_mode=combined_injection_mode,
|
|
444
|
+
tool_expectations={
|
|
445
|
+
"known": sorted(domain.all_tools),
|
|
446
|
+
"required": {sc.id: sorted(required_tools(sc)) for _id, sc in items},
|
|
447
|
+
},
|
|
431
448
|
)
|
|
432
449
|
report._scenarios_run = len(all_chaos)
|
|
433
450
|
report._skipped = skipped
|
|
@@ -441,7 +458,7 @@ def quick_test_all(
|
|
|
441
458
|
return report
|
|
442
459
|
|
|
443
460
|
|
|
444
|
-
def assert_passes(report: Report, save_html_on_failure: Optional[str] = None) -> None:
|
|
461
|
+
def assert_passes(report: Report, save_html_on_failure: Optional[str] = None, allow_inconclusive: bool = False) -> None:
|
|
445
462
|
"""Assert every scenario in `report` passed -- chaos, and clean too when
|
|
446
463
|
this was a robustness-mode run -- the one-line pytest integration: call
|
|
447
464
|
this at the end of a test instead of hand-picking which Report fields
|
|
@@ -456,9 +473,15 @@ def assert_passes(report: Report, save_html_on_failure: Optional[str] = None) ->
|
|
|
456
473
|
Pass `save_html_on_failure="path.html"` to also write the full
|
|
457
474
|
render_html() report there when the assertion fails, so a failed CI run
|
|
458
475
|
leaves something to open instead of just a pass/fail count.
|
|
476
|
+
|
|
477
|
+
A run where failures were expected but none fired is "inconclusive": every scenario
|
|
478
|
+
"passes" only because nothing was tested. That is not a pass, so it raises too, unless
|
|
479
|
+
you pass `allow_inconclusive=True`.
|
|
459
480
|
"""
|
|
460
481
|
c = report._compute()
|
|
461
482
|
failures = []
|
|
483
|
+
if report.inconclusive and not allow_inconclusive:
|
|
484
|
+
failures.append("inconclusive: no failure was introduced in any scenario, so the passes prove nothing")
|
|
462
485
|
if c["clean_passed"] is not None and c["clean_passed"] != c["clean_total"]:
|
|
463
486
|
failures.append(f"clean pass rate {c['clean_passed']}/{c['clean_total']}")
|
|
464
487
|
if c["chaos_passed"] != c["n"]:
|
|
@@ -310,6 +310,9 @@ class Report:
|
|
|
310
310
|
themselves. 0.0 (and omitted from output) for none/hardcoded/replay
|
|
311
311
|
injectors, which never call a model at all."""
|
|
312
312
|
injection_mode: Optional[str] = None
|
|
313
|
+
tool_expectations: Optional[dict] = None
|
|
314
|
+
"""{"known": [tool names the test defines], "required": {scenario_id: [tools its goal needs]}}, set
|
|
315
|
+
by quick_test/quick_test_all so the report can say when the agent and the test disagree about tools."""
|
|
313
316
|
"""How this run's failures were chosen: "exploration" (an adaptive injector
|
|
314
317
|
decided them -- finds new weaknesses, but two runs aren't directly
|
|
315
318
|
comparable) or "pinned" (a recorded sequence was replayed -- the same
|
|
@@ -424,9 +427,35 @@ class Report:
|
|
|
424
427
|
"""Plain-language form of injection_mode for report headers."""
|
|
425
428
|
return {
|
|
426
429
|
"exploration": "adaptive failures (exploration: not directly comparable with other runs)",
|
|
427
|
-
"pinned": "
|
|
430
|
+
"pinned": "pinned failures (a fixed or replayed set: comparable with other runs that use the same failures)",
|
|
428
431
|
}.get(self.injection_mode or "")
|
|
429
432
|
|
|
433
|
+
def fault_landing(self) -> dict:
|
|
434
|
+
"""Did the failures this run was meant to introduce actually fire? See toolcheck.py."""
|
|
435
|
+
from agentprobe.toolcheck import fault_landing, faults_expected_for
|
|
436
|
+
|
|
437
|
+
return fault_landing(self.chaos_trajectories, faults_expected_for(self.injector_model))
|
|
438
|
+
|
|
439
|
+
@property
|
|
440
|
+
def inconclusive(self) -> bool:
|
|
441
|
+
"""True when failures were expected but none fired in any scenario: every pass below is
|
|
442
|
+
meaningless as evidence of robustness."""
|
|
443
|
+
return self.fault_landing()["inconclusive"]
|
|
444
|
+
|
|
445
|
+
def tool_check(self) -> Optional[dict]:
|
|
446
|
+
"""Where the agent and the test disagree about tools, or None when this report wasn't
|
|
447
|
+
built with that information (an older caller) or they agree."""
|
|
448
|
+
from agentprobe.toolcheck import required_never_called, unknown_tool_calls
|
|
449
|
+
|
|
450
|
+
exp = self.tool_expectations
|
|
451
|
+
if not exp:
|
|
452
|
+
return None
|
|
453
|
+
unknown = unknown_tool_calls([*self.clean_trajectories, *self.chaos_trajectories], exp.get("known", []))
|
|
454
|
+
never = required_never_called(exp.get("required", {}), self.clean_trajectories)
|
|
455
|
+
if not unknown and not never:
|
|
456
|
+
return None
|
|
457
|
+
return {"unknown_calls": unknown, "required_never_called": never}
|
|
458
|
+
|
|
430
459
|
def priorities(self) -> list[dict]:
|
|
431
460
|
"""The run's issues ranked highest to lowest priority, with any
|
|
432
461
|
`priority_overrides` applied. See priority.py for how it's scored."""
|
|
@@ -498,6 +527,21 @@ class Report:
|
|
|
498
527
|
else:
|
|
499
528
|
lines.append(f"{purple('clean pass rate:')} n/a (no control run in recovery mode -- see spec section 12)")
|
|
500
529
|
lines.append(f"{purple('chaos pass rate:')} {c['chaos_passed']:2d}/{c['n']}")
|
|
530
|
+
landing = self.fault_landing()
|
|
531
|
+
if landing["expected"] and landing["not_landed"]:
|
|
532
|
+
if landing["inconclusive"]:
|
|
533
|
+
lines.append(bold(f"INCONCLUSIVE: no failure was introduced in any of the {landing['scenarios']} scenarios, so the pass rates above say nothing about robustness."))
|
|
534
|
+
else:
|
|
535
|
+
lines.append(
|
|
536
|
+
f"{landing['landed']} of {landing['scenarios']} scenarios had a failure land; the rest tested nothing: "
|
|
537
|
+
+ ", ".join(landing["not_landed"])
|
|
538
|
+
)
|
|
539
|
+
tc = self.tool_check()
|
|
540
|
+
if tc:
|
|
541
|
+
if tc["unknown_calls"]:
|
|
542
|
+
lines.append("tool check: your agent called tools this test does not define: " + ", ".join(f"{k} (x{v})" for k, v in tc["unknown_calls"].items()))
|
|
543
|
+
for sid, tools in tc["required_never_called"].items():
|
|
544
|
+
lines.append(f"tool check: in {sid} your agent never tried {', '.join(tools)}, which the test expects")
|
|
501
545
|
if c["target_cost_usd"] > 0:
|
|
502
546
|
cost = cyan(f"${c['target_cost_usd']:.4f}")
|
|
503
547
|
lines.append(f"{dim('target API cost:')} {cost}")
|
|
@@ -827,6 +871,7 @@ class Report:
|
|
|
827
871
|
)
|
|
828
872
|
|
|
829
873
|
priorities_html = self._priorities_html(esc, c) + self._prompt_fixes_html(esc, c)
|
|
874
|
+
trust_html = self._landing_html(esc) + self._tool_check_html(esc)
|
|
830
875
|
failures_html = self._failures_html(esc, c)
|
|
831
876
|
|
|
832
877
|
return f"""<!doctype html>
|
|
@@ -841,6 +886,7 @@ class Report:
|
|
|
841
886
|
<div class="subtitle">mode: {esc(self.mode)} · injector: {esc(self.injector_model)} · target: {esc(self.target_model)} · {esc(c['n'])} scenarios{(' · ' + esc(self.injection_mode_label())) if self.injection_mode_label() else ''}</div>
|
|
842
887
|
</header>
|
|
843
888
|
{injector_error_banner}
|
|
889
|
+
{trust_html}
|
|
844
890
|
<div class="stats">
|
|
845
891
|
{"".join(stats_html)}
|
|
846
892
|
</div>
|
|
@@ -904,6 +950,44 @@ class Report:
|
|
|
904
950
|
f"<tbody>{''.join(rows)}</tbody></table></div></section><!--/priority-table-->"
|
|
905
951
|
)
|
|
906
952
|
|
|
953
|
+
def _landing_html(self, esc) -> str:
|
|
954
|
+
"""A banner when failures were expected but did not (all) land: a pass in such a scenario
|
|
955
|
+
is not evidence of anything."""
|
|
956
|
+
landing = self.fault_landing()
|
|
957
|
+
if not landing["expected"] or not landing["not_landed"]:
|
|
958
|
+
return ""
|
|
959
|
+
if landing["inconclusive"]:
|
|
960
|
+
head = "Inconclusive: no failure was introduced"
|
|
961
|
+
body = (
|
|
962
|
+
f"None of the {landing['scenarios']} scenarios had a failure land, so the pass rates below say nothing about "
|
|
963
|
+
"how your agent handles failures. This usually means the test is aimed at tools or steps your agent never reaches. "
|
|
964
|
+
"Check the tool list and run again."
|
|
965
|
+
)
|
|
966
|
+
else:
|
|
967
|
+
head = f"{landing['landed']} of {landing['scenarios']} scenarios had a failure land"
|
|
968
|
+
body = (
|
|
969
|
+
"These scenarios tested nothing, so a pass in them is not evidence of robustness: "
|
|
970
|
+
+ ", ".join(esc(x) for x in landing["not_landed"]) + "."
|
|
971
|
+
)
|
|
972
|
+
return f"<div class='injector-error-banner trust-banner'><strong>{esc(head)}</strong>{body}</div>"
|
|
973
|
+
|
|
974
|
+
def _tool_check_html(self, esc) -> str:
|
|
975
|
+
tc = self.tool_check()
|
|
976
|
+
if not tc:
|
|
977
|
+
return ""
|
|
978
|
+
items = []
|
|
979
|
+
if tc["unknown_calls"]:
|
|
980
|
+
calls = ", ".join(f"<code>{esc(k)}</code> (x{v})" for k, v in tc["unknown_calls"].items())
|
|
981
|
+
items.append(f"<li>Your agent called tools the test does not define: {calls}. The test has no answer for them.</li>")
|
|
982
|
+
for sid, tools in tc["required_never_called"].items():
|
|
983
|
+
names = ", ".join(f"<code>{esc(t)}</code>" for t in tools)
|
|
984
|
+
items.append(f"<li><code>{esc(sid)}</code>: your agent never tried {names}, which the test expects.</li>")
|
|
985
|
+
return (
|
|
986
|
+
"<div class='injector-error-banner trust-banner'><strong>Tool check: the test and your agent disagree about tools</strong>"
|
|
987
|
+
"A mismatch means failures aimed at those tools cannot land. Give the test your real tool definitions and run again."
|
|
988
|
+
f"<ul>{''.join(items)}</ul></div>"
|
|
989
|
+
)
|
|
990
|
+
|
|
907
991
|
def _prompt_fixes_html(self, esc, c: dict) -> str:
|
|
908
992
|
""""Add these to your system prompt": general rules for the failure kinds this run found,
|
|
909
993
|
in priority order, right after the priorities table. The markers around it let the
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Did the test and the agent agree about which tools exist, and did the failures land?
|
|
2
|
+
|
|
3
|
+
Two quiet ways a run can look clean without proving anything:
|
|
4
|
+
|
|
5
|
+
* the test was built around tool names the agent doesn't have (so a failure aimed at
|
|
6
|
+
`cancel_order` never fires because the agent calls `cancelOrder`), and
|
|
7
|
+
* no failure was actually introduced in a scenario, so "passed" says nothing.
|
|
8
|
+
|
|
9
|
+
This module holds the pure checks; Report uses them to say so, and quick_test_all can run
|
|
10
|
+
the first one before spending anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Iterable, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def required_tools(scenario) -> set[str]:
|
|
19
|
+
"""Every tool the scenario's goal depends on: required commits and required facts."""
|
|
20
|
+
goal = scenario.goal
|
|
21
|
+
return {p.tool for p in goal.required_commits} | {p.tool for p in goal.required_facts}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_agent_tools(scenarios: Iterable, agent_tools: Iterable[str]) -> list[str]:
|
|
25
|
+
"""Problems between what the scenarios need and the tool names the agent really has. Empty
|
|
26
|
+
when they agree. Call this BEFORE a run: a test aimed at a tool the agent doesn't have cannot
|
|
27
|
+
fire its failure, and the result would be a clean-looking pass."""
|
|
28
|
+
have = set(agent_tools)
|
|
29
|
+
problems = []
|
|
30
|
+
for sc in scenarios:
|
|
31
|
+
missing = sorted(required_tools(sc) - have)
|
|
32
|
+
if missing:
|
|
33
|
+
problems.append(f"{sc.id}: needs {', '.join(missing)}, which the agent does not have")
|
|
34
|
+
return problems
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def unknown_tool_calls(trajectories: Iterable, known_tools: Iterable[str]) -> dict[str, int]:
|
|
38
|
+
"""Tool names the agent called that the test does not define, with how often. The agent
|
|
39
|
+
reached for something the test world has no answer for."""
|
|
40
|
+
known = set(known_tools)
|
|
41
|
+
out: dict[str, int] = {}
|
|
42
|
+
for t in trajectories:
|
|
43
|
+
for s in t.steps:
|
|
44
|
+
if s.tool_name not in known:
|
|
45
|
+
out[s.tool_name] = out.get(s.tool_name, 0) + 1
|
|
46
|
+
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def required_never_called(scenario_ids_to_required: dict[str, list[str]], clean_trajectories: Iterable) -> dict[str, list[str]]:
|
|
50
|
+
"""Per scenario, the required tools the agent never even tried in its clean run. Usually
|
|
51
|
+
this means the agent uses a different name for the same action."""
|
|
52
|
+
called: dict[str, set[str]] = {}
|
|
53
|
+
for t in clean_trajectories:
|
|
54
|
+
called.setdefault(t.scenario_id, set()).update(s.tool_name for s in t.steps)
|
|
55
|
+
out = {}
|
|
56
|
+
for sid, req in scenario_ids_to_required.items():
|
|
57
|
+
if sid not in called:
|
|
58
|
+
continue
|
|
59
|
+
missing = sorted(set(req) - called[sid])
|
|
60
|
+
if missing:
|
|
61
|
+
out[sid] = missing
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fault_landing(chaos_trajectories: Iterable, faults_expected: bool) -> dict:
|
|
66
|
+
"""Whether the failures the run was meant to introduce actually fired. A scenario where none
|
|
67
|
+
fired was never tested for robustness, whatever its pass/fail says."""
|
|
68
|
+
trajs = list(chaos_trajectories)
|
|
69
|
+
not_landed = [t.scenario_id for t in trajs if not t.injections and not getattr(t, "discarded", False)]
|
|
70
|
+
n = len(trajs)
|
|
71
|
+
landed = n - len(not_landed)
|
|
72
|
+
return {
|
|
73
|
+
"expected": faults_expected,
|
|
74
|
+
"scenarios": n,
|
|
75
|
+
"landed": landed,
|
|
76
|
+
"not_landed": not_landed,
|
|
77
|
+
"inconclusive": bool(faults_expected and n > 0 and landed == 0),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
NO_FAULT_INJECTORS = ("NullInjector", "none")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def faults_expected_for(injector_model: Optional[str]) -> bool:
|
|
85
|
+
"""False for a run that deliberately injects nothing (a clean baseline)."""
|
|
86
|
+
return (injector_model or "") not in NO_FAULT_INJECTORS
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Turn the tool definitions you already have into the list AgentProbe tests against.
|
|
2
|
+
|
|
3
|
+
Accepts, and auto-detects, any of:
|
|
4
|
+
* AgentProbe/Anthropic tools [{"name", "description", "input_schema"}]
|
|
5
|
+
* OpenAI function calling [{"type": "function", "function": {"name", "description", "parameters"}}]
|
|
6
|
+
or [{"name", "description", "parameters"}]
|
|
7
|
+
* an MCP server's tools/list reply {"tools": [{"name", "description", "inputSchema"}]}
|
|
8
|
+
(or the whole JSON-RPC reply, {"result": {"tools": [...]}})
|
|
9
|
+
* an OpenAPI 3 / Swagger 2 document (one tool per operation)
|
|
10
|
+
|
|
11
|
+
Real tool definitions matter: a test built around guessed tool names can aim a failure at a
|
|
12
|
+
tool the agent doesn't have, so it never fires. server/src/toolimport.js is a port of this file
|
|
13
|
+
for the website; tests/fixtures/tool_import is shared by both and a test on each side checks them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
MAX_TOOLS = 100
|
|
23
|
+
_HTTP_METHODS = ("get", "post", "put", "patch", "delete")
|
|
24
|
+
_NAME_OK = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clean_name(raw: str) -> str:
|
|
28
|
+
name = re.sub(r"[^A-Za-z0-9_-]+", "_", str(raw)).strip("_")
|
|
29
|
+
return name[:64] or "tool"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tool(name: Any, description: Any, schema: Any) -> dict:
|
|
33
|
+
if not isinstance(schema, dict) or not schema:
|
|
34
|
+
schema = {"type": "object", "properties": {}}
|
|
35
|
+
if "type" not in schema:
|
|
36
|
+
schema = {"type": "object", **schema}
|
|
37
|
+
return {"name": _clean_name(name), "description": str(description or "")[:1000], "input_schema": schema}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _from_list(items: list) -> list[dict]:
|
|
41
|
+
out = []
|
|
42
|
+
for it in items:
|
|
43
|
+
if not isinstance(it, dict):
|
|
44
|
+
raise ValueError("each tool must be an object")
|
|
45
|
+
fn = it.get("function") if it.get("type") == "function" and isinstance(it.get("function"), dict) else it
|
|
46
|
+
name = fn.get("name")
|
|
47
|
+
if not name:
|
|
48
|
+
raise ValueError("a tool has no name")
|
|
49
|
+
schema = fn.get("input_schema") or fn.get("inputSchema") or fn.get("parameters")
|
|
50
|
+
out.append(_tool(name, fn.get("description"), schema))
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve(node: Any, spec: dict, depth: int = 0) -> Any:
|
|
55
|
+
"""Inline local $refs (#/components/schemas/X, #/definitions/X), a few levels deep."""
|
|
56
|
+
if depth > 5:
|
|
57
|
+
return {}
|
|
58
|
+
if isinstance(node, dict):
|
|
59
|
+
ref = node.get("$ref")
|
|
60
|
+
if isinstance(ref, str) and ref.startswith("#/"):
|
|
61
|
+
target: Any = spec
|
|
62
|
+
for part in ref[2:].split("/"):
|
|
63
|
+
target = target.get(part, {}) if isinstance(target, dict) else {}
|
|
64
|
+
return _resolve(target, spec, depth + 1)
|
|
65
|
+
return {k: _resolve(v, spec, depth + 1) for k, v in node.items()}
|
|
66
|
+
if isinstance(node, list):
|
|
67
|
+
return [_resolve(v, spec, depth + 1) for v in node]
|
|
68
|
+
return node
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _from_openapi(spec: dict) -> list[dict]:
|
|
72
|
+
out = []
|
|
73
|
+
for path, item in (spec.get("paths") or {}).items():
|
|
74
|
+
if not isinstance(item, dict):
|
|
75
|
+
continue
|
|
76
|
+
shared = item.get("parameters") or []
|
|
77
|
+
for method in _HTTP_METHODS:
|
|
78
|
+
op = item.get(method)
|
|
79
|
+
if not isinstance(op, dict):
|
|
80
|
+
continue
|
|
81
|
+
props: dict = {}
|
|
82
|
+
required: list = []
|
|
83
|
+
for p in [*shared, *(op.get("parameters") or [])]:
|
|
84
|
+
p = _resolve(p, spec)
|
|
85
|
+
if not isinstance(p, dict) or "name" not in p:
|
|
86
|
+
continue
|
|
87
|
+
sch = dict(_resolve(p.get("schema") or {"type": p.get("type", "string")}, spec))
|
|
88
|
+
if p.get("description"):
|
|
89
|
+
sch["description"] = p["description"]
|
|
90
|
+
props[p["name"]] = sch
|
|
91
|
+
if p.get("required") or p.get("in") == "path":
|
|
92
|
+
required.append(p["name"])
|
|
93
|
+
body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema")
|
|
94
|
+
if body:
|
|
95
|
+
body = _resolve(body, spec)
|
|
96
|
+
if isinstance(body, dict) and isinstance(body.get("properties"), dict):
|
|
97
|
+
props.update(body["properties"])
|
|
98
|
+
required += [r for r in body.get("required", []) if r not in required]
|
|
99
|
+
else:
|
|
100
|
+
props["body"] = body
|
|
101
|
+
schema = {"type": "object", "properties": props}
|
|
102
|
+
if required:
|
|
103
|
+
schema["required"] = sorted(set(required))
|
|
104
|
+
name = op.get("operationId") or f"{method}_{path}"
|
|
105
|
+
out.append(_tool(name, op.get("summary") or op.get("description"), schema))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def normalize_tools(source: Any) -> list[dict]:
|
|
110
|
+
"""The tool list for `source` (a parsed object or a JSON string), in AgentProbe's shape.
|
|
111
|
+
Raises ValueError with a readable reason if it isn't a format above, or has no tools."""
|
|
112
|
+
if isinstance(source, (str, bytes)):
|
|
113
|
+
try:
|
|
114
|
+
source = json.loads(source)
|
|
115
|
+
except json.JSONDecodeError as e:
|
|
116
|
+
raise ValueError(f"not valid JSON: {e}") from None
|
|
117
|
+
if isinstance(source, dict) and isinstance(source.get("result"), dict) and "tools" in source["result"]:
|
|
118
|
+
source = source["result"]
|
|
119
|
+
if isinstance(source, list):
|
|
120
|
+
tools = _from_list(source)
|
|
121
|
+
elif isinstance(source, dict) and isinstance(source.get("tools"), list):
|
|
122
|
+
tools = _from_list(source["tools"])
|
|
123
|
+
elif isinstance(source, dict) and ("openapi" in source or "swagger" in source):
|
|
124
|
+
tools = _from_openapi(source)
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"unrecognized tool definitions: expected a list of tools, an MCP tools/list reply, or an OpenAPI document"
|
|
128
|
+
)
|
|
129
|
+
if not tools:
|
|
130
|
+
raise ValueError("no tools found in that file")
|
|
131
|
+
seen: dict[str, int] = {}
|
|
132
|
+
unique = []
|
|
133
|
+
for t in tools:
|
|
134
|
+
n = seen.get(t["name"], 0)
|
|
135
|
+
seen[t["name"]] = n + 1
|
|
136
|
+
if n:
|
|
137
|
+
t = {**t, "name": f"{t['name'][:60]}_{n + 1}"}
|
|
138
|
+
unique.append(t)
|
|
139
|
+
if len(unique) > MAX_TOOLS:
|
|
140
|
+
raise ValueError(f"too many tools ({len(unique)}); the limit is {MAX_TOOLS}. Send only the ones your agent uses")
|
|
141
|
+
return unique
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentprobe-testing
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: Adaptive chaos-testing for LLM agents: a live model-driven Injector that reads an agent's real trajectory and decides where to break something, instead of scripting perturbations in advance.
|
|
5
5
|
License: Business Source License 1.1
|
|
6
6
|
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/SOURCES.txt
RENAMED
|
@@ -24,6 +24,8 @@ agentprobe/savings.py
|
|
|
24
24
|
agentprobe/scenario.py
|
|
25
25
|
agentprobe/summarize.py
|
|
26
26
|
agentprobe/termui.py
|
|
27
|
+
agentprobe/toolcheck.py
|
|
28
|
+
agentprobe/toolimport.py
|
|
27
29
|
agentprobe/tools.py
|
|
28
30
|
agentprobe/trajectory.py
|
|
29
31
|
agentprobe/triage.py
|
|
@@ -116,6 +118,8 @@ tests/test_scenarios.py
|
|
|
116
118
|
tests/test_target_agent.py
|
|
117
119
|
tests/test_tau_retail.py
|
|
118
120
|
tests/test_termui.py
|
|
121
|
+
tests/test_toolcheck.py
|
|
122
|
+
tests/test_toolimport.py
|
|
119
123
|
tests/test_tools.py
|
|
120
124
|
tests/test_trajectory.py
|
|
121
125
|
tests/test_triage.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agentprobe-testing"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.7.0"
|
|
4
4
|
description = "Adaptive chaos-testing for LLM agents: a live model-driven Injector that reads an agent's real trajectory and decides where to break something, instead of scripting perturbations in advance."
|
|
5
5
|
requires-python = ">=3.11"
|
|
6
6
|
dependencies = ["anthropic>=1.0.0", "python-dotenv>=1.0.0"]
|
|
@@ -33,8 +33,8 @@ def test_injection_mode_reads_the_injector_type_through_a_recording_wrapper():
|
|
|
33
33
|
def test_a_replayed_run_is_labeled_pinned_in_the_report():
|
|
34
34
|
report = quick_test(BY_ID["clean-1"], AGENT, injector=ReplayInjector([]), mode="recovery")
|
|
35
35
|
assert report.injection_mode == "pinned"
|
|
36
|
-
assert "
|
|
37
|
-
assert "failures:
|
|
36
|
+
assert "pinned failures (a fixed or replayed set: comparable with other runs that use the same failures)" in report.render_html()
|
|
37
|
+
assert "failures: pinned failures (a fixed or replayed set" in report.render()
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
def test_recording_a_replay_still_labels_it_pinned():
|
|
@@ -65,3 +65,9 @@ def test_the_requester_uses_the_step_timeout_helper_and_no_hardcoded_sixty_secon
|
|
|
65
65
|
assert "_step_timeout(deadline, time.monotonic())" in SRC
|
|
66
66
|
# every run builds its requester with a deadline
|
|
67
67
|
assert "_make_httpx_requester(deadline=time.monotonic() + budget)" in SRC
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_the_runner_reports_whether_a_run_was_inconclusive():
|
|
71
|
+
# entry.py can't be imported here (it needs Cloudflare's `workers`), so check the source: the
|
|
72
|
+
# website relies on this field to show INCONCLUSIVE instead of a meaningless PASS.
|
|
73
|
+
assert '"inconclusive": report.inconclusive' in SRC
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
8
|
+
|
|
9
|
+
from agentprobe.agents.base import AgentAction # noqa: E402
|
|
10
|
+
from agentprobe.agents.scripted import ScriptedAgent # noqa: E402
|
|
11
|
+
from agentprobe.classifier import ResponseClassification as RC # noqa: E402
|
|
12
|
+
from agentprobe.injector import NullInjector # noqa: E402
|
|
13
|
+
from agentprobe.quickstart import assert_passes, quick_test, quick_test_all # noqa: E402
|
|
14
|
+
from agentprobe.savings import fixed_fault_for # noqa: E402
|
|
15
|
+
from agentprobe.scenarios.registry import BY_ID # noqa: E402
|
|
16
|
+
from agentprobe.toolcheck import check_agent_tools, fault_landing, required_tools # noqa: E402
|
|
17
|
+
from test_failures import _report # noqa: E402
|
|
18
|
+
|
|
19
|
+
S1 = BY_ID["clean-1"]
|
|
20
|
+
REAL = ["get_ticket", "issue_refund", "close_ticket"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _decide(task, history):
|
|
24
|
+
calls = [h["tool_name"] for h in history]
|
|
25
|
+
if "get_ticket" not in calls:
|
|
26
|
+
return AgentAction(kind="tool_call", tool_name="get_ticket", tool_args={"id": "T-1001"})
|
|
27
|
+
if "issue_refund" not in calls:
|
|
28
|
+
return AgentAction(kind="tool_call", tool_name="issue_refund", tool_args={"ticket_id": "T-1001", "amount": 49.99})
|
|
29
|
+
if "close_ticket" not in calls:
|
|
30
|
+
return AgentAction(kind="tool_call", tool_name="close_ticket", tool_args={"ticket_id": "T-1001", "resolution": "refunded"})
|
|
31
|
+
return AgentAction(kind="final_answer", text="done")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---- #2 the preflight: does the agent have the tools the tests need? ----
|
|
35
|
+
def test_required_tools_come_from_the_goal():
|
|
36
|
+
assert {"issue_refund"} <= required_tools(S1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_check_agent_tools_names_what_is_missing():
|
|
40
|
+
assert check_agent_tools([S1], REAL) == []
|
|
41
|
+
problems = check_agent_tools([S1], ["get_ticket", "refund"])
|
|
42
|
+
assert problems and "issue_refund" in problems[0] and S1.id in problems[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_quick_test_all_refuses_to_spend_when_the_agent_lacks_a_required_tool():
|
|
46
|
+
ran = []
|
|
47
|
+
|
|
48
|
+
def spy(task, history):
|
|
49
|
+
ran.append(1)
|
|
50
|
+
return AgentAction(kind="final_answer", text="x")
|
|
51
|
+
|
|
52
|
+
with pytest.raises(ValueError, match="nothing was spent"):
|
|
53
|
+
quick_test_all([S1], spy, agent_tools=["get_ticket", "refund"])
|
|
54
|
+
assert ran == []
|
|
55
|
+
quick_test_all([S1], _decide, agent_tools=REAL) # a matching list runs
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- #5 the probe: the clean run reveals what the agent actually calls ----
|
|
59
|
+
def test_the_report_says_when_the_agent_reaches_for_a_tool_the_test_does_not_define():
|
|
60
|
+
def calls_a_different_name(task, history):
|
|
61
|
+
if not history:
|
|
62
|
+
return AgentAction(kind="tool_call", tool_name="lookup_ticket", tool_args={"id": "T-1001"})
|
|
63
|
+
return AgentAction(kind="final_answer", text="done")
|
|
64
|
+
|
|
65
|
+
report = quick_test_all([S1], calls_a_different_name)
|
|
66
|
+
tc = report.tool_check()
|
|
67
|
+
assert tc["unknown_calls"] == {"lookup_ticket": 2} # clean and chaos runs
|
|
68
|
+
assert S1.id in tc["required_never_called"] and "issue_refund" in tc["required_never_called"][S1.id]
|
|
69
|
+
html = report.render_html()
|
|
70
|
+
assert "the test and your agent disagree about tools" in html and "lookup_ticket" in html
|
|
71
|
+
assert "tool check:" in report.render()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_no_tool_check_noise_when_they_agree():
|
|
75
|
+
report = quick_test_all([S1], _decide)
|
|
76
|
+
assert report.tool_check() is None
|
|
77
|
+
assert "disagree about tools" not in report.render_html()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---- #3 did the failures land? ----
|
|
81
|
+
def test_a_run_where_no_failure_fired_is_inconclusive_not_clean():
|
|
82
|
+
# Fault aimed at a tool the (scripted) agent never calls: nothing ever fires.
|
|
83
|
+
def never_commits(task, history):
|
|
84
|
+
return AgentAction(kind="final_answer", text="done")
|
|
85
|
+
|
|
86
|
+
report = quick_test_all([S1], never_commits, injector_factory=lambda sc: fixed_fault_for(sc))
|
|
87
|
+
landing = report.fault_landing()
|
|
88
|
+
assert landing["inconclusive"] and landing["not_landed"] == [S1.id] and landing["landed"] == 0
|
|
89
|
+
assert report.inconclusive
|
|
90
|
+
html = report.render_html()
|
|
91
|
+
assert "Inconclusive: no failure was introduced" in html
|
|
92
|
+
assert "INCONCLUSIVE" in report.render()
|
|
93
|
+
with pytest.raises(AssertionError, match="inconclusive"):
|
|
94
|
+
assert_passes(report)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_a_run_where_the_failure_landed_is_not_inconclusive():
|
|
98
|
+
report = quick_test_all([S1], _decide, injector_factory=lambda sc: fixed_fault_for(sc))
|
|
99
|
+
assert report.fault_landing()["landed"] == 1 and not report.inconclusive
|
|
100
|
+
assert "Inconclusive" not in report.render_html()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_deliberate_clean_run_is_not_called_inconclusive():
|
|
104
|
+
report = quick_test(S1, _decide, injector=NullInjector())
|
|
105
|
+
assert not report.inconclusive and not report.fault_landing()["expected"]
|
|
106
|
+
assert "Inconclusive" not in report.render_html()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_partial_landing_is_called_out_by_scenario():
|
|
110
|
+
chaos_ok = _report([("a", False, RC.IGNORED)]).chaos_trajectories
|
|
111
|
+
quiet = _report([("b", True, RC.HANDLED)]).chaos_trajectories
|
|
112
|
+
quiet = [dataclasses.replace(t, injections=[]) for t in quiet]
|
|
113
|
+
landing = fault_landing([*chaos_ok, *quiet], True)
|
|
114
|
+
assert landing["landed"] == 1 and landing["not_landed"] == ["b"] and not landing["inconclusive"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from agentprobe.toolimport import MAX_TOOLS, normalize_tools
|
|
7
|
+
|
|
8
|
+
FIX = os.path.join(os.path.dirname(__file__), "fixtures", "tool_import")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("name", ["openai_functions", "mcp_tools_list", "openapi_small"])
|
|
12
|
+
def test_each_format_normalizes_to_the_expected_tools(name):
|
|
13
|
+
raw = open(os.path.join(FIX, f"{name}.json")).read()
|
|
14
|
+
expected = json.load(open(os.path.join(FIX, f"{name}.expected.json")))
|
|
15
|
+
assert normalize_tools(raw) == expected
|
|
16
|
+
assert normalize_tools(json.loads(raw)) == expected # a parsed object works too
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_agentprobe_and_anthropic_shape_passes_through():
|
|
20
|
+
tools = [{"name": "get_ticket", "description": "d", "input_schema": {"type": "object", "properties": {"id": {"type": "string"}}}}]
|
|
21
|
+
assert normalize_tools(tools) == tools
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_names_are_made_safe_and_unique():
|
|
25
|
+
out = normalize_tools([{"name": "a.b c"}, {"name": "a.b c"}, {"name": "!!!"}])
|
|
26
|
+
assert [t["name"] for t in out] == ["a_b_c", "a_b_c_2", "tool"]
|
|
27
|
+
assert all(t["input_schema"]["type"] == "object" for t in out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.parametrize(
|
|
31
|
+
"bad,match",
|
|
32
|
+
[("{not json", "not valid JSON"), ({"hello": 1}, "unrecognized"), ([], "no tools"), ([{"description": "x"}], "no name"), (["x"], "object")],
|
|
33
|
+
)
|
|
34
|
+
def test_unusable_input_gets_a_readable_error(bad, match):
|
|
35
|
+
with pytest.raises(ValueError, match=match):
|
|
36
|
+
normalize_tools(bad)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_too_many_tools_is_refused():
|
|
40
|
+
with pytest.raises(ValueError, match="too many"):
|
|
41
|
+
normalize_tools([{"name": f"t{i}"} for i in range(MAX_TOOLS + 1)])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_self_referencing_openapi_schemas_do_not_loop():
|
|
45
|
+
spec = {"openapi": "3.0.0", "components": {"schemas": {"A": {"type": "object", "properties": {"a": {"$ref": "#/components/schemas/A"}}}}},
|
|
46
|
+
"paths": {"/x": {"post": {"operationId": "x", "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/A"}}}}}}}}
|
|
47
|
+
assert normalize_tools(spec)[0]["name"] == "x"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/__init__.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/agent.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/clean.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/decoy.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/domain.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/entities.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/scenarios.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/split.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/tools.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/access_control/trap.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/__init__.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/chat_agent.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/domain.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/loader.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/oracle.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/tools.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/user_sim.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/tau_retail/world.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/__init__.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/agent.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/clean.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/domain.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/entities.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/scenarios.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/tools.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe/domains/web_browsing/trap.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/agentprobe_testing.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_rule_based_agent.py
RENAMED
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_access_control_scenarios.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentprobe_testing-1.6.0 → agentprobe_testing-1.7.0}/tests/test_complex_access_control_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|