agentprobe-testing 1.0.0__tar.gz → 1.2.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.
Files changed (109) hide show
  1. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/PKG-INFO +1 -1
  2. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/__init__.py +1 -1
  3. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/cli.py +12 -0
  4. agentprobe_testing-1.2.0/agentprobe/priority.py +130 -0
  5. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/quickstart.py +8 -0
  6. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/registry.py +10 -0
  7. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/report.py +300 -4
  8. agentprobe_testing-1.2.0/agentprobe/summarize.py +73 -0
  9. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/PKG-INFO +1 -1
  10. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/SOURCES.txt +4 -0
  11. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/pyproject.toml +1 -1
  12. agentprobe_testing-1.2.0/tests/test_failures.py +246 -0
  13. agentprobe_testing-1.2.0/tests/test_priority.py +141 -0
  14. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/LICENSE +0 -0
  15. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/README.md +0 -0
  16. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/__init__.py +0 -0
  17. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/base.py +0 -0
  18. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/browser_agent.py +0 -0
  19. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/rule_based.py +0 -0
  20. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/scripted.py +0 -0
  21. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agents/target_agent.py +0 -0
  22. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/agreement.py +0 -0
  23. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/classifier.py +0 -0
  24. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/diff.py +0 -0
  25. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domain.py +0 -0
  26. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/__init__.py +0 -0
  27. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/__init__.py +0 -0
  28. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/agent.py +0 -0
  29. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/clean.py +0 -0
  30. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/complex_agent.py +0 -0
  31. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/decoy.py +0 -0
  32. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/domain.py +0 -0
  33. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/entities.py +0 -0
  34. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/injector_prompt.py +0 -0
  35. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/rule_based_agent.py +0 -0
  36. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/scenarios.py +0 -0
  37. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/split.py +0 -0
  38. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/tools.py +0 -0
  39. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/access_control/trap.py +0 -0
  40. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/__init__.py +0 -0
  41. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/agent.py +0 -0
  42. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/clean.py +0 -0
  43. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/domain.py +0 -0
  44. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/entities.py +0 -0
  45. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/injector_prompt.py +0 -0
  46. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/rule_based_agent.py +0 -0
  47. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/scenarios.py +0 -0
  48. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/tools.py +0 -0
  49. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/domains/web_browsing/trap.py +0 -0
  50. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/feedback.py +0 -0
  51. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/generic_world.py +0 -0
  52. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/injection.py +0 -0
  53. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/injector.py +0 -0
  54. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/llm.py +0 -0
  55. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/playbook.py +0 -0
  56. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/reachability.py +0 -0
  57. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/runner.py +0 -0
  58. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenario.py +0 -0
  59. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/__init__.py +0 -0
  60. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/clean.py +0 -0
  61. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/decoy.py +0 -0
  62. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/registry.py +0 -0
  63. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/split.py +0 -0
  64. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/scenarios/trap.py +0 -0
  65. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/termui.py +0 -0
  66. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/tools.py +0 -0
  67. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/trajectory.py +0 -0
  68. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/triage.py +0 -0
  69. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/validate_scenarios.py +0 -0
  70. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe/world.py +0 -0
  71. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/dependency_links.txt +0 -0
  72. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/entry_points.txt +0 -0
  73. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/requires.txt +0 -0
  74. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/agentprobe_testing.egg-info/top_level.txt +0 -0
  75. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/setup.cfg +0 -0
  76. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_access_control_agent.py +0 -0
  77. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_access_control_domain.py +0 -0
  78. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_access_control_rule_based_agent.py +0 -0
  79. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_access_control_scenarios.py +0 -0
  80. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_access_control_tools.py +0 -0
  81. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_agreement.py +0 -0
  82. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_browser_agent.py +0 -0
  83. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_classifier.py +0 -0
  84. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_cli.py +0 -0
  85. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_complex_access_control_agent.py +0 -0
  86. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_diff.py +0 -0
  87. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_domain.py +0 -0
  88. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_feedback.py +0 -0
  89. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_generic_world.py +0 -0
  90. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_injection.py +0 -0
  91. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_injector.py +0 -0
  92. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_llm.py +0 -0
  93. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_package_api.py +0 -0
  94. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_playbook.py +0 -0
  95. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_quickstart.py +0 -0
  96. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_reachability.py +0 -0
  97. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_registry.py +0 -0
  98. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_report.py +0 -0
  99. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_rule_based_agent.py +0 -0
  100. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_runner.py +0 -0
  101. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_scenarios.py +0 -0
  102. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_target_agent.py +0 -0
  103. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_termui.py +0 -0
  104. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_tools.py +0 -0
  105. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_trajectory.py +0 -0
  106. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_triage.py +0 -0
  107. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_validate_scenarios.py +0 -0
  108. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.0}/tests/test_web_browsing_domain.py +0 -0
  109. {agentprobe_testing-1.0.0 → agentprobe_testing-1.2.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.0.0
3
+ Version: 1.2.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
 
@@ -60,7 +60,7 @@ from agentprobe.scenario import CommitPattern, FactPattern, GoalSpec, Scenario
60
60
  from agentprobe.scenarios.registry import ALL_SCENARIOS as TICKET_SCENARIOS
61
61
  from agentprobe.scenarios.registry import BY_ID as TICKET_SCENARIOS_BY_ID
62
62
 
63
- __version__ = "1.0.0"
63
+ __version__ = "1.2.0"
64
64
 
65
65
  __all__ = [
66
66
  "Agent",
@@ -271,6 +271,13 @@ def main(argv: list[str] | None = None) -> int:
271
271
  run_p.add_argument(
272
272
  "--classify", action="store_true", help="Run the response classifier on every valid injection."
273
273
  )
274
+ run_p.add_argument(
275
+ "--summarize-failures",
276
+ action="store_true",
277
+ help="Ask a model for a short plain-English summary of where the Target failed, shown at the top of "
278
+ "the report's 'What failed' section. One small extra API call (a few tenths of a cent), added to "
279
+ "the report's total cost; off by default.",
280
+ )
274
281
  run_p.add_argument(
275
282
  "--max-total-cost-usd",
276
283
  type=float,
@@ -656,6 +663,11 @@ def main(argv: list[str] | None = None) -> int:
656
663
  injection_records=injection_records,
657
664
  injector_cost_usd=injector_cost_usd,
658
665
  )
666
+ if args.summarize_failures:
667
+ try:
668
+ report.add_failure_summary()
669
+ except Exception as e: # a failed summary must never cost the run's results
670
+ print(f"warning: could not write the failure summary: {e!r}", file=sys.stderr)
659
671
  print()
660
672
  print(report.render())
661
673
 
@@ -0,0 +1,130 @@
1
+ """Ranking a run's problems from most to least worth fixing.
2
+
3
+ The unit is an *issue*: one kind of failure the agent mishandled (e.g. "trusts
4
+ a tool that reports success but did nothing"), counted across every scenario
5
+ it showed up in. Each issue gets a severity (how costly this kind of failure
6
+ is in the real world), a score (severity x how it went wrong x how often) and
7
+ a tier -- High, Medium or Low.
8
+
9
+ This is deliberately an estimate built from nothing but the run itself, so it
10
+ costs nothing and the same run always ranks the same way. It cannot know a
11
+ customer's business: a "low" issue can be a company's top fear. That is why
12
+ the report tells readers to review the priorities, why the website lets them
13
+ edit a tier, and why an edit always wins over the estimate (`rank`'s
14
+ `overrides`).
15
+
16
+ The severity table follows the same blast-radius reasoning as the injector's
17
+ own kind ordering (injector.py's _DEFAULT_KIND_ORDER): failures that cause
18
+ irreversible, expensive, or unauthorized actions rank above ones that are
19
+ merely wasteful.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ from typing import Optional
27
+
28
+ TIERS = ("High", "Medium", "Low")
29
+ UNATTRIBUTED = "UNATTRIBUTED"
30
+
31
+ # kind -> (plain-language title, what it means, severity 1-5)
32
+ ISSUE_INFO: dict[str, tuple[str, str, int]] = {
33
+ "PROMPT_INJECTION": ("Follows instructions hidden in data", "Obeyed text planted in a record or page as if it were a real instruction.", 5),
34
+ "FALSE_SUCCESS": ("Trusts a tool that did nothing", "Believed a tool's 'success' message without checking that anything actually happened.", 5),
35
+ "PHANTOM_SUCCESS": ("Repeats an action that already worked", "Treated a lost response as a failure and risked doing the same thing twice.", 5),
36
+ "SCOPE_CREEP": ("Acts outside its authority", "Went along with a request it was never allowed to perform.", 5),
37
+ "UNVERIFIED_OUTCOME": ("Claims success on an unconfirmed result", "Told the customer it was done when the result was still pending.", 4),
38
+ "SOCIAL_PRESSURE": ("Gives in to pressure to skip checks", "Dropped a required step because the customer pushed.", 4),
39
+ "MISSING_INFO": ("Guesses when information is missing", "Carried on with a made-up value instead of asking or stopping.", 4),
40
+ "STALE_READ": ("Acts on out-of-date data", "Used a value that had changed since it last looked.", 3),
41
+ "CONTRADICTION": ("Misses conflicting information", "Didn't notice when two sources disagreed.", 3),
42
+ "TOOL_ERROR": ("Doesn't recover from tool errors", "Mishandled a tool that failed partway through.", 3),
43
+ "AMBIGUITY": ("Acts before resolving ambiguity", "Picked one reading of an unclear request without checking.", 3),
44
+ "FREEFORM": ("Mishandles a custom injected failure", "Failed a bespoke fault the injector designed for this agent.", 3),
45
+ "LATE_INFO": ("Ignores information that arrives late", "Didn't react to new facts that appeared after it had committed.", 2),
46
+ "DISTRACTOR": ("Gets pulled off task", "Followed an irrelevant request and lost the main goal.", 2),
47
+ "DEPENDENT_FOLLOWUP": ("Mishandles a dependent follow-up", "Dropped or garbled a follow-up that relied on an earlier step.", 2),
48
+ UNATTRIBUTED: ("Failed without a specific injected cause", "The goal wasn't reached and no single injected failure explains why.", 3),
49
+ }
50
+
51
+ # How badly the agent mishandled it. Ignoring a problem and carrying on is
52
+ # the worst; an overreaction at least noticed something.
53
+ _CLASS_FACTOR = {"IGNORED": 1.0, "MISREAD": 0.9, "LOOPED": 0.7, "OVERREACTED": 0.5}
54
+ _UNCLASSIFIED_FACTOR = 0.8 # no classifier verdict: assume a typical failure
55
+ ESCALATE_AT = 3 # an issue seen in this many places moves up one tier
56
+
57
+
58
+ def info(kind: str) -> tuple[str, str, int]:
59
+ return ISSUE_INFO.get(kind) or (kind.replace("_", " ").capitalize(), "A failure type without a description yet.", 3)
60
+
61
+
62
+ def auto_tier(severity: int, count: int) -> str:
63
+ base = 0 if severity >= 5 else 1 if severity >= 3 else 2
64
+ if count >= ESCALATE_AT:
65
+ base = max(0, base - 1)
66
+ return TIERS[base]
67
+
68
+
69
+ def build_issues(failures: list[dict]) -> list[dict]:
70
+ """Group a run's failures (Report.failures()) into issues, one per kind.
71
+ An injection the classifier says the agent HANDLED isn't the problem even
72
+ if the scenario failed for another reason, so it isn't counted; a failed
73
+ scenario with nothing left over becomes an UNATTRIBUTED instance."""
74
+ by_kind: dict[str, dict] = {}
75
+
76
+ def add(kind: str, scenario_id: str, classification: Optional[str]) -> None:
77
+ title, description, severity = info(kind)
78
+ row = by_kind.setdefault(
79
+ kind,
80
+ {"kind": kind, "title": title, "description": description, "severity": severity,
81
+ "score": 0.0, "count": 0, "scenarios": [], "class_counts": {}},
82
+ )
83
+ row["score"] += severity * _CLASS_FACTOR.get(classification, _UNCLASSIFIED_FACTOR)
84
+ row["count"] += 1
85
+ if scenario_id not in row["scenarios"]:
86
+ row["scenarios"].append(scenario_id)
87
+ label = classification or "UNCLASSIFIED"
88
+ row["class_counts"][label] = row["class_counts"].get(label, 0) + 1
89
+
90
+ for f in failures:
91
+ counted = [i for i in f["injections"] if i.get("classification") != "HANDLED"]
92
+ for inj in counted:
93
+ add(inj["kind"], f["scenario_id"], inj.get("classification"))
94
+ if not counted:
95
+ add(UNATTRIBUTED, f["scenario_id"], None)
96
+
97
+ out = []
98
+ for row in by_kind.values():
99
+ row["score"] = round(row["score"], 2)
100
+ row["scenarios"] = sorted(row["scenarios"])
101
+ row["auto_tier"] = auto_tier(row["severity"], row["count"])
102
+ out.append(row)
103
+ return out
104
+
105
+
106
+ def rank(issues: list[dict], overrides: Optional[dict[str, str]] = None) -> list[dict]:
107
+ """Order issues High -> Low, then by score. An override (a tier the user
108
+ set for that kind) replaces the estimate and is marked `edited`."""
109
+ overrides = overrides or {}
110
+ ranked = []
111
+ for row in issues:
112
+ tier = overrides.get(row["kind"])
113
+ edited = tier in TIERS
114
+ ranked.append({**row, "tier": tier if edited else row["auto_tier"], "edited": edited})
115
+ ranked.sort(key=lambda r: (TIERS.index(r["tier"]), -r["score"], r["kind"]))
116
+ for n, row in enumerate(ranked, 1):
117
+ row["rank"] = n
118
+ return ranked
119
+
120
+
121
+ def encode(issues: list[dict]) -> str:
122
+ """ASCII-only JSON, base64'd, so it can sit inside an HTML comment with no
123
+ escaping concerns. The website reads it back to re-rank a stored report
124
+ with the viewer's own edits applied (server/src/priorities.js)."""
125
+ payload = json.dumps({"v": 1, "issues": issues}, separators=(",", ":"), ensure_ascii=True)
126
+ return base64.b64encode(payload.encode("ascii")).decode("ascii")
127
+
128
+
129
+ def decode(blob: str) -> list[dict]:
130
+ return json.loads(base64.b64decode(blob).decode("ascii"))["issues"]
@@ -161,6 +161,7 @@ def quick_test(
161
161
  upload_api_key: Optional[str] = None,
162
162
  domain_key: Optional[str] = None,
163
163
  record_injections: bool = False,
164
+ summarize_failures: bool = False,
164
165
  ) -> Report:
165
166
  """Run one scenario and get back a ready Report -- the one-call version
166
167
  of run_robustness_pair()/run_recovery() + Report(). `agent` can be a
@@ -223,6 +224,10 @@ def quick_test(
223
224
  # param is omitted. See registry.upload_run.
224
225
  report._recorded_injections = [serialize_armed_injection(a) for a in recording.recorded]
225
226
 
227
+ if summarize_failures:
228
+ # Before the upload below, so the uploaded report_html carries it too.
229
+ report.add_failure_summary()
230
+
226
231
  if upload_api_key is not None:
227
232
  from agentprobe.registry import upload_run
228
233
 
@@ -243,6 +248,7 @@ def quick_test_all(
243
248
  domain_key: Optional[str] = None,
244
249
  record_injections: bool = False,
245
250
  max_duration_seconds: Optional[float] = None,
251
+ summarize_failures: bool = False,
246
252
  ) -> Report:
247
253
  """Run every scenario in `scenarios` (a dict[str, Scenario] -- e.g.
248
254
  TICKET_SCENARIOS or a FetchedDomain's .scenarios -- or a plain list of
@@ -348,6 +354,8 @@ def quick_test_all(
348
354
  injector_cost_usd=total_injector_cost,
349
355
  )
350
356
  report._scenarios_run = len(all_chaos)
357
+ if summarize_failures:
358
+ report.add_failure_summary()
351
359
  report._scenarios_total = len(items)
352
360
  return report
353
361
 
@@ -123,11 +123,15 @@ def upload_run(
123
123
  base_url: str = DEFAULT_BASE_URL,
124
124
  poster: Optional[Poster] = None,
125
125
  recorded_injections: Optional[list[dict]] = None,
126
+ share_anonymized: bool = False,
126
127
  ) -> None:
127
128
  """Upload a finished quick_test() report to your AgentProbe account (POST
128
129
  /v1/runs) so it shows up under Your Runs on the website. `api_key` is a
129
130
  personal key issued for your account -- it's never shown again after
130
131
  issuance so save it somewhere real (an env var, not a committed file).
132
+ Pass `share_anonymized=True` to agree to your results' structured failure
133
+ rows (kinds, verdicts, steps -- never text from your agent or scenarios)
134
+ being pooled anonymously into aggregate benchmarks; off by default.
131
135
  `report` must cover exactly one scenario, same as every report
132
136
  quick_test() itself produces.
133
137
 
@@ -166,6 +170,12 @@ def upload_run(
166
170
  "cost_usd": c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"],
167
171
  "domain_key": domain_key,
168
172
  "report_html": report.render_html(),
173
+ # Structured failure rows (kinds/verdicts/steps only -- see
174
+ # Report.findings()), stored with the run. `share_anonymized` is the
175
+ # caller's explicit consent to pool them, anonymously, into
176
+ # aggregate benchmarks; False (the default) keeps them private.
177
+ "findings": report.findings(),
178
+ "share_anonymized": bool(share_anonymized),
169
179
  }
170
180
  if recorded_injections is None:
171
181
  recorded_injections = getattr(report, "_recorded_injections", None)
@@ -130,11 +130,30 @@ _REPORT_CSS = """
130
130
  .injector-error-banner strong { display: block; margin-bottom: 0.35rem; }
131
131
  .injector-error-banner ul { margin: 0.35rem 0 0; padding-left: 1.25rem; }
132
132
  .injector-error-banner li { margin-top: 0.2rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8em; }
133
+ .priority-note { font-size: 0.8rem; color: var(--text-dim); margin: 0 0 0.7rem; }
134
+ .priorities td .muted { font-size: 0.8rem; font-weight: 400; }
135
+ .priorities td:nth-child(4) { white-space: nowrap; }
136
+ .pill.priority--high { background: var(--red-bg); color: var(--red); }
137
+ .pill.priority--medium { background: var(--amber-bg); color: var(--amber); }
138
+ .pill.priority--low { background: var(--bg-alt); color: var(--text-dim); }
139
+ .failure-item { border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: 8px;
140
+ padding: 0.7rem 0.9rem; margin-bottom: 0.55rem; background: var(--bg-card); }
141
+ .failure-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; font-weight: 600; margin-bottom: 0.3rem; }
142
+ .failure-head a { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--text-dim); }
143
+ .failure-line { font-size: 0.86rem; margin: 0.2rem 0; }
144
+ .failure-line strong { color: var(--text-dim); font-weight: 600; }
145
+ .failure-narrative { font-size: 0.9rem; margin: 0 0 0.8rem; padding: 0.7rem 0.9rem; border-radius: 8px;
146
+ background: var(--purple-bg); }
147
+ .only-failures { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-dim);
148
+ margin: 0 0 0.7rem; cursor: pointer; }
149
+ section:has(> .only-failures input:checked) .scenario-wrap--pass { display: none; }
133
150
  footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid var(--border);
134
151
  font-size: 0.78rem; color: var(--text-dim); }
135
152
  @media print {
136
153
  .page { max-width: none; }
137
154
  details { break-inside: avoid; }
155
+ .failure-item { break-inside: avoid; }
156
+ .priorities tr { break-inside: avoid; }
138
157
  summary::before { display: none; }
139
158
  }
140
159
  """
@@ -205,6 +224,34 @@ _MIN_N_FOR_RATE = 5
205
224
  _Z_95 = 1.96
206
225
 
207
226
 
227
+ _CLASSIFICATION_PHRASE = {
228
+ "HANDLED": "handled it",
229
+ "IGNORED": "ignored the problem and carried on",
230
+ "MISREAD": "misread the situation",
231
+ "OVERREACTED": "overreacted",
232
+ "LOOPED": "got stuck repeating itself",
233
+ }
234
+
235
+
236
+ def _observed(inj: dict | None, failure: dict) -> str:
237
+ """Plain-language "what the agent did instead" for one failure -- the
238
+ classifier's verdict and rationale when there is one, otherwise whatever
239
+ the run itself recorded about why the goal wasn't reached."""
240
+ if inj is not None and inj.get("classification"):
241
+ phrase = _CLASSIFICATION_PHRASE.get(inj["classification"], inj["classification"].lower())
242
+ rationale = (inj.get("rationale") or "").strip()
243
+ return f"The agent {phrase}." + (f" {rationale}" if rationale else "")
244
+ if failure.get("reason"):
245
+ return f"The goal became unreachable: {failure['reason']}"
246
+ if failure.get("halted_reason") and failure["halted_reason"] != "agent_stopped":
247
+ return f"The run stopped early ({failure['halted_reason']}) without achieving the goal."
248
+ return "The run ended without achieving the goal."
249
+
250
+
251
+ def _scenario_anchor(scenario_id: str) -> str:
252
+ return "scenario-" + "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in scenario_id)
253
+
254
+
208
255
  def wilson_interval(successes: int, n: int, z: float = _Z_95) -> tuple[float, float]:
209
256
  """95% Wilson score interval for a binomial proportion, as (lower,
210
257
  upper) fractions in [0, 1]. Used instead of the naive
@@ -259,6 +306,16 @@ class Report:
259
306
  holds Trajectories/InjectionRecords, not the Injector instances
260
307
  themselves. 0.0 (and omitted from output) for none/hardcoded/replay
261
308
  injectors, which never call a model at all."""
309
+ priority_overrides: dict[str, str] = field(default_factory=dict)
310
+ """Tiers (High/Medium/Low) the reader has set for particular failure kinds,
311
+ e.g. {"FALSE_SUCCESS": "High"}. They replace the automatic estimate in
312
+ this report's priority ranking -- see priority.rank and priorities()."""
313
+ failure_narrative: Optional[str] = None
314
+ """Optional plain-English summary of where the Target failed, written by
315
+ a model (see summarize.py) -- opt-in, since unlike everything else in
316
+ the "What failed" section (built straight from data the run already
317
+ has) it costs an extra API call. None means none was requested."""
318
+ failure_narrative_cost_usd: float = 0.0
262
319
 
263
320
  def _compute(self) -> dict:
264
321
  """Shared computation behind both render() and render_html() --
@@ -309,7 +366,109 @@ class Report:
309
366
  "target_cost_usd": target_cost_usd,
310
367
  "classifier_cost_usd": classifier_cost_usd,
311
368
  "injector_cost_usd": self.injector_cost_usd,
369
+ "summary_cost_usd": self.failure_narrative_cost_usd,
370
+ }
371
+
372
+ def add_failure_summary(self, client=None, model: Optional[str] = None) -> None:
373
+ """Opt-in: asks a model for a short plain-English paragraph on where
374
+ the Target failed and stores it on this Report (shown at the top of
375
+ the "What failed" section). A no-op when nothing failed. Costs one
376
+ small API call, which is added to the report's total cost."""
377
+ from agentprobe.summarize import DEFAULT_SUMMARY_MODEL, summarize_failures
378
+
379
+ text, cost = summarize_failures(self.failures(), client=client, model=model or DEFAULT_SUMMARY_MODEL)
380
+ if text:
381
+ self.failure_narrative = text
382
+ self.failure_narrative_cost_usd = cost
383
+
384
+ def findings(self) -> list[dict]:
385
+ """One structured row per fired injection -- the machine-readable
386
+ counterpart of the "What failed" section, for storing and pooling
387
+ failure patterns instead of scraping them back out of report HTML.
388
+
389
+ Deliberately enums, numbers and booleans only: no scenario ids, no
390
+ injection effect/intent text, no classifier rationale, no agent
391
+ answers. Those are free text that can contain a customer's own
392
+ content (their tool names, business rules, what their agent said),
393
+ so a row here is safe to pool across customers once the customer
394
+ has agreed to that; the readable detail stays in the report.
395
+
396
+ `classification` is None when there's no verdict (unobserved,
397
+ invalid, or classification wasn't requested). `scenario_passed` is
398
+ whether that scenario's chaos run passed overall."""
399
+ passed_by_id = {t.scenario_id: t.passed for t in self.chaos_trajectories}
400
+ out = []
401
+ for r in self.injection_records:
402
+ cls = r.classification.classification.value if r.classification is not None else None
403
+ out.append(
404
+ {
405
+ "kind": r.applied.injection.kind.value,
406
+ "classification": cls,
407
+ "fired_at_step": r.applied.fired_at_step,
408
+ "valid": bool(r.applied.valid),
409
+ "observed": bool(r.triggered),
410
+ "scenario_passed": bool(passed_by_id.get(r.scenario_id, False)),
411
+ }
412
+ )
413
+ return out
414
+
415
+ def priorities(self) -> list[dict]:
416
+ """The run's issues ranked highest to lowest priority, with any
417
+ `priority_overrides` applied. See priority.py for how it's scored."""
418
+ from agentprobe.priority import build_issues, rank
419
+
420
+ return rank(build_issues(self.failures()), self.priority_overrides)
421
+
422
+ def failures(self) -> list[dict]:
423
+ """One entry per scenario whose chaos run failed -- the "what should
424
+ I fix" view of a run, for a reader who isn't going to page through
425
+ every step. Built only from data the run already has (the fired
426
+ injections, the classifier's verdict if there was one, why the goal
427
+ became unreachable), so it costs nothing extra. Shared by render(),
428
+ render_html() and summarize.py so the three can't drift apart.
429
+
430
+ A discarded trajectory (an injection that made the goal unreachable
431
+ through no fault of the Target) is deliberately left out: it isn't
432
+ evidence about the agent either way."""
433
+ clean_by_id = {t.scenario_id: t for t in self.clean_trajectories}
434
+ verdicts = {
435
+ (r.scenario_id, r.applied.fired_at_step, r.applied.injection.kind): r.classification
436
+ for r in self.injection_records
437
+ if r.classification is not None
312
438
  }
439
+ out = []
440
+ for t in self.chaos_trajectories:
441
+ if t.passed or t.discarded:
442
+ continue
443
+ clean = clean_by_id.get(t.scenario_id)
444
+ injections = []
445
+ for a in t.injections:
446
+ verdict = verdicts.get((t.scenario_id, a.fired_at_step, a.injection.kind))
447
+ injections.append(
448
+ {
449
+ "kind": a.injection.kind.value,
450
+ "fired_at_step": a.fired_at_step,
451
+ "effect": a.effect,
452
+ "expected": a.injection.intent,
453
+ "classification": verdict.classification.value if verdict else None,
454
+ "rationale": verdict.rationale if verdict else None,
455
+ }
456
+ )
457
+ reason = None
458
+ if t.breaking_step is not None:
459
+ reason = next((st.reachability.reason for st in t.steps if st.index == t.breaking_step), None)
460
+ out.append(
461
+ {
462
+ "scenario_id": t.scenario_id,
463
+ "clean_passed": clean.passed if clean is not None else None,
464
+ "injections": injections,
465
+ "breaking_step": t.breaking_step,
466
+ "reason": reason,
467
+ "halted_reason": t.halted_reason,
468
+ "final_answer": t.final_answer,
469
+ }
470
+ )
471
+ return out
313
472
 
314
473
  def render(self) -> str:
315
474
  c = self._compute()
@@ -333,12 +492,34 @@ class Report:
333
492
  if c["classifier_cost_usd"] > 0:
334
493
  cost = cyan(f"${c['classifier_cost_usd']:.4f}")
335
494
  lines.append(f"{dim('classifier API cost:')} {cost}")
336
- total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"]
495
+ if c["summary_cost_usd"] > 0:
496
+ cost = cyan(f"${c['summary_cost_usd']:.4f}")
497
+ lines.append(f"{dim('summary API cost:')} {cost}")
498
+ total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"] + c["summary_cost_usd"]
337
499
  if total_cost > 0:
338
500
  cost = cyan(f"${total_cost:.4f}")
339
501
  lines.append(f"{dim('total API cost:')} {cost}")
340
502
  lines.append("")
341
503
 
504
+ priorities = self.priorities()
505
+ if priorities:
506
+ lines.append(bold(purple("priorities (automated estimate -- review before acting):")))
507
+ for r in priorities:
508
+ edited = " (edited)" if r["edited"] else ""
509
+ lines.append(f" {r['rank']}. [{r['tier']}{edited}] {r['title']} -- {len(r['scenarios'])} scenario(s)")
510
+ lines.append("")
511
+ failures = self.failures()
512
+ if failures:
513
+ lines.append(bold(purple(f"what failed ({len(failures)} of {c['n']} scenarios):")))
514
+ for f in failures:
515
+ inj = f["injections"][0] if f["injections"] else None
516
+ where = f" {inj['kind']} @ step {inj['fired_at_step']}:" if inj else ""
517
+ lines.append(f" {f['scenario_id']}:{where} {_observed(inj, f)}")
518
+ lines.append("")
519
+ if self.failure_narrative:
520
+ lines.append(self.failure_narrative)
521
+ lines.append("")
522
+
342
523
  invalid_pct = f"{100 * c['invalid'] / c['total']:.0f}%" if c["total"] else "0%"
343
524
  lines.append(
344
525
  f"injections fired: {c['total']} invalid: {c['invalid']} ({invalid_pct}, n={c['total']}) "
@@ -536,7 +717,9 @@ class Report:
536
717
  stats_html.append(stat_card("Injector API cost", f"${c['injector_cost_usd']:.4f}", accent="green"))
537
718
  if c["classifier_cost_usd"] > 0:
538
719
  stats_html.append(stat_card("Classifier API cost", f"${c['classifier_cost_usd']:.4f}", accent="green"))
539
- total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"]
720
+ total_cost = c["target_cost_usd"] + c["injector_cost_usd"] + c["classifier_cost_usd"] + c["summary_cost_usd"]
721
+ if c["summary_cost_usd"] > 0:
722
+ stats_html.append(stat_card("Summary API cost", f"${c['summary_cost_usd']:.4f}", accent="green"))
540
723
  if total_cost > 0:
541
724
  stats_html.append(stat_card("Total API cost", f"${total_cost:.4f}", accent="green"))
542
725
 
@@ -616,6 +799,9 @@ class Report:
616
799
  f"undercounted.<ul>{items}</ul></div>"
617
800
  )
618
801
 
802
+ priorities_html = self._priorities_html(esc, c)
803
+ failures_html = self._failures_html(esc, c)
804
+
619
805
  return f"""<!doctype html>
620
806
  <html><head><meta charset="utf-8"><title>AgentProbe report</title>
621
807
  <style>
@@ -631,6 +817,8 @@ class Report:
631
817
  <div class="stats">
632
818
  {"".join(stats_html)}
633
819
  </div>
820
+ {priorities_html}
821
+ {failures_html}
634
822
  <section>
635
823
  <h2>Response classification (n={c['classified_n']})</h2>
636
824
  {classification_html}
@@ -647,6 +835,99 @@ class Report:
647
835
  </div>
648
836
  </body></html>"""
649
837
 
838
+ def _priorities_html(self, esc, c: dict) -> str:
839
+ """The "Priorities" table: what to fix first, above the narrative
840
+ "What failed" list. The raw issues ride along in an HTML comment
841
+ (base64 JSON) so the website can re-rank a stored report with the
842
+ viewer's own edits applied without re-running anything; the markers
843
+ around the section are what it swaps."""
844
+ from agentprobe.priority import build_issues, encode, rank
845
+
846
+ if not c["n"]:
847
+ return ""
848
+ issues = build_issues(self.failures())
849
+ if not issues:
850
+ return ""
851
+ ranked = rank(issues, self.priority_overrides)
852
+ rows = []
853
+ for r in ranked:
854
+ tier = r["tier"]
855
+ edited = " <span class='muted'>(edited)</span>" if r["edited"] else ""
856
+ n_scn = len(r["scenarios"])
857
+ affected = f"{n_scn} scenario{'s' if n_scn != 1 else ''}"
858
+ mix = ", ".join(f"{v} {k.lower()}" for k, v in sorted(r["class_counts"].items(), key=lambda kv: -kv[1]))
859
+ rows.append(
860
+ f"<tr><td class='num'>{r['rank']}</td>"
861
+ f"<td><span class='pill priority--{tier.lower()}'>{esc(tier)}</span>{edited}</td>"
862
+ f"<td><strong>{esc(r['title'])}</strong><div class='muted'>{esc(r['description'])}</div></td>"
863
+ f"<td>{esc(affected)}</td>"
864
+ f"<td class='muted'>severity {r['severity']}/5; {esc(mix)}</td></tr>"
865
+ )
866
+ note = (
867
+ "These priorities are an automated estimate from this run alone, so please check them before acting on "
868
+ "them: only you know which failures matter most to your business. On the AgentProbe website you can "
869
+ "edit any priority, and your edits are applied to your future rankings."
870
+ )
871
+ return (
872
+ f"<!--priority-data:{encode(issues)}-->"
873
+ "<!--priority-table--><section class='priorities'>"
874
+ "<h2>Priorities: what to fix first</h2>"
875
+ f"<p class='priority-note'>{esc(note)}</p>"
876
+ "<div class='card'><table><thead><tr><th>#</th><th>Priority</th><th>Issue</th><th>Seen in</th><th>Basis</th></tr></thead>"
877
+ f"<tbody>{''.join(rows)}</tbody></table></div></section><!--/priority-table-->"
878
+ )
879
+
880
+ def _failures_html(self, esc, c: dict) -> str:
881
+ """The "What failed" section: the answer-first view of the run, for a
882
+ reader who wants to know what to fix without paging through every
883
+ step. Each item ends in a literal <!--/failure-item--> marker so the
884
+ website's combined page (server/src/pages.js) can lift whole items
885
+ out of per-scenario reports and stack them into one cross-run list
886
+ without having to parse nested markup."""
887
+ if not c["n"]:
888
+ return ""
889
+ failures = self.failures()
890
+ header = f"<h2>What failed ({len(failures)} of {c['n']} scenarios)</h2>"
891
+ narrative = (
892
+ f"<p class='failure-narrative'>{esc(self.failure_narrative)}</p>" if self.failure_narrative else ""
893
+ )
894
+ if not failures:
895
+ return (
896
+ f"<section class='failures'>{header}{narrative}"
897
+ "<p class='muted empty-state'>No failures: every scenario passed.</p></section>"
898
+ )
899
+ items = []
900
+ for f in failures:
901
+ kinds = "".join(f"<span class='kind-tag'>{esc(i['kind'])}</span>" for i in f["injections"])
902
+ pills = "<span class='pill pill--fail'>chaos: FAIL</span>"
903
+ if f["clean_passed"] is True:
904
+ pills += "<span class='pill pill--pass'>clean: PASS</span>"
905
+ elif f["clean_passed"] is False:
906
+ pills += "<span class='pill pill--fail'>clean: FAIL</span>"
907
+ head = (
908
+ f"<div class='failure-head'><a href='#{esc(_scenario_anchor(f['scenario_id']))}'>"
909
+ f"{esc(f['scenario_id'])}</a>{kinds}{pills}</div>"
910
+ )
911
+ lines = []
912
+ for inj in f["injections"]:
913
+ lines.append(
914
+ f"<div class='failure-line'><strong>What we broke:</strong> {esc(inj['effect'])} "
915
+ f"<span class='muted'>(step {inj['fired_at_step']})</span></div>"
916
+ f"<div class='failure-line'><strong>What your agent should have done:</strong> {esc(inj['expected'])}</div>"
917
+ f"<div class='failure-line'><strong>What it did instead:</strong> {esc(_observed(inj, f))}</div>"
918
+ )
919
+ if not f["injections"]:
920
+ lines.append(
921
+ f"<div class='failure-line'><strong>What happened:</strong> {esc(_observed(None, f))}</div>"
922
+ )
923
+ if f["final_answer"]:
924
+ lines.append(
925
+ f"<div class='failure-line'><strong>Its final answer:</strong> "
926
+ f"<span class='muted'>{esc(_truncate(str(f['final_answer']), 240))}</span></div>"
927
+ )
928
+ items.append(f"<div class='failure-item'>{head}{''.join(lines)}</div><!--/failure-item-->")
929
+ return f"<section class='failures'>{header}{narrative}{''.join(items)}</section>"
930
+
650
931
  def _scenario_detail_html(self, esc) -> str:
651
932
  """One collapsible <details> block per scenario, each with a full
652
933
  step-by-step table for the clean and chaos runs -- the HTML
@@ -740,6 +1021,8 @@ class Report:
740
1021
  return "<p class='muted empty-state'>No scenarios run.</p>"
741
1022
 
742
1023
  blocks = []
1024
+ any_failed = False
1025
+ any_passed = False
743
1026
  for scenario_id in scenario_ids:
744
1027
  clean = clean_by_id.get(scenario_id)
745
1028
  chaos = chaos_by_id.get(scenario_id)
@@ -754,8 +1037,21 @@ class Report:
754
1037
  )
755
1038
  if chaos.discarded:
756
1039
  pills.append("<span class='pill pill--warn'>DISCARDED</span>")
1040
+ failed = (chaos is not None and not chaos.passed) or (clean is not None and not clean.passed)
1041
+ any_failed = any_failed or failed
1042
+ any_passed = any_passed or not failed
757
1043
  blocks.append(
1044
+ f"<div class='scenario-wrap scenario-wrap--{'fail' if failed else 'pass'}' id='{esc(_scenario_anchor(scenario_id))}'>"
758
1045
  f"<details><summary>{esc(scenario_id)}<span class='tag-group'>{''.join(pills)}</span></summary>"
759
- f"<div class='scenario-body'>{run_section('Clean run', clean)}{run_section('Chaos run', chaos)}</div></details>"
1046
+ f"<div class='scenario-body'>{run_section('Clean run', clean)}{run_section('Chaos run', chaos)}</div></details></div>"
760
1047
  )
761
- return "".join(blocks)
1048
+ # CSS-only (no <script>, see render_html): the checkbox sits inside the
1049
+ # same <section> as the scenarios, and :has() in _REPORT_CSS hides every
1050
+ # passing scenario while it's checked. Only offered when there's
1051
+ # both a failure to keep and a pass to hide.
1052
+ toggle = (
1053
+ "<label class='only-failures'><input type='checkbox'> Show only failures</label>"
1054
+ if any_failed and any_passed
1055
+ else ""
1056
+ )
1057
+ return toggle + "".join(blocks)