test_tool_failure_bridge.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. from __future__ import annotations
  2. import json
  3. import pytest
  4. from agent.orchestration import CompletionPolicy
  5. from agent.trace.store import FileSystemTraceStore
  6. from script_build_host.agents.presets import register_script_presets
  7. from script_build_host.application.phase_two_candidates import PhaseTwoCandidateError
  8. from script_build_host.domain.errors import (
  9. ArtifactDigestMismatch,
  10. MissionFencingTokenStale,
  11. PublicationLockTimeout,
  12. ScriptBuildError,
  13. )
  14. from script_build_host.tools.failures import classify_script_tool_failure
  15. from script_build_host.tools.registry import register_script_tools
  16. from agent import (
  17. AgentRunner,
  18. FailureDetail,
  19. FailureDisposition,
  20. RunConfig,
  21. ToolRegistry,
  22. )
  23. class _Coordinator:
  24. async def ensure_ledger(self, *_args, **_kwargs):
  25. return None
  26. def _knowledge_off():
  27. from agent.tools.builtin.knowledge import KnowledgeConfig
  28. return KnowledgeConfig(
  29. enable_extraction=False,
  30. enable_completion_extraction=False,
  31. enable_injection=False,
  32. )
  33. @pytest.mark.parametrize(
  34. ("error", "expected"),
  35. [
  36. (
  37. PhaseTwoCandidateError("INPUT_SCOPE_MISMATCH", "scope is invalid"),
  38. FailureDisposition.REPLAN_TASK,
  39. ),
  40. (
  41. PhaseTwoCandidateError("GOAL_COVERAGE_INCOMPLETE", "goal is missing"),
  42. FailureDisposition.REPLAN_TASK,
  43. ),
  44. (
  45. PhaseTwoCandidateError("LEGACY_WRITE_INVALID", "workspace is invalid"),
  46. FailureDisposition.REPAIR_ATTEMPT,
  47. ),
  48. (PublicationLockTimeout(), FailureDisposition.RETRY_CALL),
  49. (MissionFencingTokenStale(), FailureDisposition.ABORT_RUN),
  50. (ArtifactDigestMismatch(), FailureDisposition.ABORT_RUN),
  51. (
  52. ScriptBuildError("UNCLASSIFIED_DOMAIN_FAILURE", "fail closed"),
  53. FailureDisposition.ABORT_RUN,
  54. ),
  55. (ValueError("bad argument"), FailureDisposition.RETRY_CALL),
  56. ],
  57. )
  58. def test_script_failure_classification_is_central_and_fail_closed(error, expected):
  59. failure = classify_script_tool_failure(
  60. error,
  61. source_tool="test_tool",
  62. context={"task_id": "task-1", "attempt_id": "attempt-1"},
  63. )
  64. assert failure.disposition is expected
  65. assert failure.source_tool == "test_tool"
  66. assert failure.details["task_id"] == "task-1"
  67. @pytest.mark.asyncio
  68. async def test_compose_scope_failure_exits_worker_and_keeps_exact_domain_error(tmp_path):
  69. class Gateway:
  70. async def save_structured_script_candidate(self, _notes, _context):
  71. raise PhaseTwoCandidateError(
  72. "INPUT_SCOPE_MISMATCH",
  73. "Paragraph has no adopted covering Structure",
  74. )
  75. register_script_presets()
  76. registry = ToolRegistry()
  77. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  78. calls = 0
  79. async def llm_call(**_kwargs):
  80. nonlocal calls
  81. calls += 1
  82. return {
  83. "content": "",
  84. "tool_calls": [{
  85. "id": "save-compose",
  86. "type": "function",
  87. "function": {
  88. "name": "save_structured_script_candidate",
  89. "arguments": "{}",
  90. },
  91. }],
  92. "finish_reason": "tool_calls",
  93. }
  94. result = await AgentRunner(
  95. trace_store=FileSystemTraceStore(str(tmp_path)),
  96. tool_registry=registry,
  97. llm_call=llm_call,
  98. task_coordinator=_Coordinator(),
  99. ).run_result(
  100. [{"role": "user", "content": "compose"}],
  101. RunConfig(
  102. agent_type="script_compose_worker",
  103. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  104. tools=["save_structured_script_candidate"],
  105. tool_groups=[],
  106. context={
  107. "root_trace_id": "root",
  108. "task_id": "compose-task",
  109. "attempt_id": "attempt-1",
  110. },
  111. knowledge=_knowledge_off(),
  112. ),
  113. )
  114. assert calls == 1
  115. assert result["status"] == "failed"
  116. assert result["failure"] == {
  117. "code": "INPUT_SCOPE_MISMATCH",
  118. "message": "Paragraph has no adopted covering Structure",
  119. "disposition": "replan_task",
  120. "source_tool": "save_structured_script_candidate",
  121. "details": {"attempt_id": "attempt-1", "task_id": "compose-task"},
  122. }
  123. @pytest.mark.asyncio
  124. async def test_workspace_error_can_be_repaired_in_same_attempt(tmp_path):
  125. class Gateway:
  126. def __init__(self):
  127. self.write_calls = 0
  128. async def candidate_command(self, name, _payload, _context):
  129. assert name == "create_script_paragraph"
  130. self.write_calls += 1
  131. if self.write_calls == 1:
  132. raise PhaseTwoCandidateError(
  133. "LEGACY_WRITE_INVALID",
  134. "paragraph dimension is duplicated",
  135. )
  136. return {"created": True}
  137. async def submit_current_attempt(self, _context):
  138. return {"attempt_id": "attempt-1", "status": "awaiting_validation"}
  139. register_script_presets()
  140. gateway = Gateway()
  141. registry = ToolRegistry()
  142. register_script_tools(registry, gateway) # type: ignore[arg-type]
  143. calls = 0
  144. async def llm_call(**_kwargs):
  145. nonlocal calls
  146. calls += 1
  147. if calls <= 2:
  148. tool_name = "create_script_paragraph"
  149. arguments = json.dumps({
  150. "paragraph_index": 1,
  151. "name": "opening",
  152. "content_range": {"start": 0, "end": 100},
  153. })
  154. else:
  155. tool_name = "submit_attempt"
  156. arguments = "{}"
  157. return {
  158. "content": "",
  159. "tool_calls": [{
  160. "id": f"call-{calls}",
  161. "type": "function",
  162. "function": {"name": tool_name, "arguments": arguments},
  163. }],
  164. "finish_reason": "tool_calls",
  165. }
  166. result = await AgentRunner(
  167. trace_store=FileSystemTraceStore(str(tmp_path)),
  168. tool_registry=registry,
  169. llm_call=llm_call,
  170. task_coordinator=_Coordinator(),
  171. ).run_result(
  172. [{"role": "user", "content": "write paragraph"}],
  173. RunConfig(
  174. agent_type="script_paragraph_worker",
  175. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  176. tools=["create_script_paragraph", "submit_attempt"],
  177. tool_groups=[],
  178. context={"task_id": "paragraph-task", "attempt_id": "attempt-1"},
  179. knowledge=_knowledge_off(),
  180. ),
  181. )
  182. assert gateway.write_calls == 2
  183. assert calls == 3
  184. assert result["status"] == "completed"
  185. assert result["failure"] is None
  186. @pytest.mark.asyncio
  187. async def test_dispatch_preserves_batch_and_surfaces_child_failure():
  188. child_failure = FailureDetail(
  189. code="INPUT_SCOPE_MISMATCH",
  190. message="scope mismatch",
  191. disposition=FailureDisposition.REPLAN_TASK,
  192. source_tool="save_structured_script_candidate",
  193. )
  194. class Gateway:
  195. async def dispatch_script_tasks(self, *, task_ids, context):
  196. assert task_ids == ["compose-task", "other-task"]
  197. assert context["root_trace_id"] == "root"
  198. return [
  199. {
  200. "task_id": "compose-task",
  201. "task_status": "needs_replan",
  202. "attempt_id": "attempt-1",
  203. "failure": child_failure.to_dict(),
  204. },
  205. {"task_id": "other-task", "task_status": "completed"},
  206. ]
  207. registry = ToolRegistry()
  208. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  209. result = await registry._tools["dispatch_script_tasks"]["func"](
  210. task_ids=["compose-task", "other-task"],
  211. context={"root_trace_id": "root"},
  212. )
  213. assert "other-task" in result.output
  214. assert result.failure.code == "INPUT_SCOPE_MISMATCH"
  215. assert result.failure.source_tool == "save_structured_script_candidate"
  216. assert result.failure.details == {
  217. "attempt_id": "attempt-1",
  218. "task_id": "compose-task",
  219. }
  220. @pytest.mark.asyncio
  221. async def test_planner_breaks_after_same_dispatched_failure_twice(tmp_path):
  222. child_failure = FailureDetail(
  223. code="INPUT_SCOPE_MISMATCH",
  224. message="scope mismatch",
  225. disposition=FailureDisposition.REPLAN_TASK,
  226. source_tool="save_structured_script_candidate",
  227. )
  228. class Gateway:
  229. async def dispatch_script_tasks(self, *, task_ids, context):
  230. return [{
  231. "task_id": task_ids[0],
  232. "task_status": "needs_replan",
  233. "attempt_id": "volatile-attempt-id",
  234. "failure": child_failure.to_dict(),
  235. }]
  236. register_script_presets()
  237. registry = ToolRegistry()
  238. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  239. calls = 0
  240. async def llm_call(**_kwargs):
  241. nonlocal calls
  242. calls += 1
  243. return {
  244. "content": "",
  245. "tool_calls": [{
  246. "id": f"dispatch-{calls}",
  247. "type": "function",
  248. "function": {
  249. "name": "dispatch_script_tasks",
  250. "arguments": json.dumps({"task_ids": ["compose-task"]}),
  251. },
  252. }],
  253. "finish_reason": "tool_calls",
  254. }
  255. result = await AgentRunner(
  256. trace_store=FileSystemTraceStore(str(tmp_path)),
  257. tool_registry=registry,
  258. llm_call=llm_call,
  259. task_coordinator=_Coordinator(),
  260. ).run_result(
  261. [{"role": "user", "content": "plan"}],
  262. RunConfig(
  263. agent_type="script_planner",
  264. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  265. tools=["dispatch_script_tasks"],
  266. tool_groups=[],
  267. root_task_spec={
  268. "objective": "deliver script",
  269. "acceptance_criteria": [{"description": "script is accepted"}],
  270. },
  271. context={"root_trace_id": "root"},
  272. knowledge=_knowledge_off(),
  273. ),
  274. )
  275. assert calls == 2
  276. assert result["status"] == "incomplete"
  277. assert result["failure"]["code"] == "NO_PROGRESS"
  278. assert result["failure"]["details"]["last_failure"]["code"] == (
  279. "INPUT_SCOPE_MISMATCH"
  280. )