test_tool_failure_bridge.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. from __future__ import annotations
  2. import json
  3. import pytest
  4. from agent import (
  5. AgentRunner,
  6. FailureDetail,
  7. FailureDisposition,
  8. RunConfig,
  9. ToolRegistry,
  10. )
  11. from agent.orchestration import CompletionPolicy
  12. from agent.trace.store import FileSystemTraceStore
  13. from script_build_host.agents.presets import register_script_presets
  14. from script_build_host.application.phase_two_candidates import PhaseTwoCandidateError
  15. from script_build_host.domain.errors import (
  16. ArtifactDigestMismatch,
  17. MissionFencingTokenStale,
  18. PublicationLockTimeout,
  19. ScriptBuildError,
  20. )
  21. from script_build_host.tools.failures import classify_script_tool_failure
  22. from script_build_host.tools.registry import register_script_tools
  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_paragraphs"
  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_paragraphs"
  149. arguments = json.dumps({
  150. "paragraphs": [{
  151. "client_key": "opening",
  152. "paragraph_index": 1,
  153. "name": "opening",
  154. "content_range": {"start": 0, "end": 100},
  155. "level": 1,
  156. "theme_elements": [{"原子点": "theme", "维度": "topic", "维度类型": "主维度"}],
  157. "form_elements": [{"原子点": "form", "维度": "contrast", "维度类型": "主维度"}],
  158. "function_elements": [{"原子点": "hook", "维度": "role", "维度类型": "主维度"}],
  159. "feeling_elements": [{"原子点": "curious", "维度": "tone"}],
  160. }],
  161. })
  162. else:
  163. tool_name = "submit_attempt"
  164. arguments = "{}"
  165. return {
  166. "content": "",
  167. "tool_calls": [{
  168. "id": f"call-{calls}",
  169. "type": "function",
  170. "function": {"name": tool_name, "arguments": arguments},
  171. }],
  172. "finish_reason": "tool_calls",
  173. }
  174. result = await AgentRunner(
  175. trace_store=FileSystemTraceStore(str(tmp_path)),
  176. tool_registry=registry,
  177. llm_call=llm_call,
  178. task_coordinator=_Coordinator(),
  179. ).run_result(
  180. [{"role": "user", "content": "write paragraph"}],
  181. RunConfig(
  182. agent_type="script_paragraph_worker",
  183. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  184. tools=["create_script_paragraphs", "submit_attempt"],
  185. tool_groups=[],
  186. context={"task_id": "paragraph-task", "attempt_id": "attempt-1"},
  187. knowledge=_knowledge_off(),
  188. ),
  189. )
  190. assert gateway.write_calls == 2
  191. assert calls == 3
  192. assert result["status"] == "completed"
  193. assert result["failure"] is None
  194. @pytest.mark.asyncio
  195. async def test_dispatch_preserves_batch_and_surfaces_child_failure():
  196. child_failure = FailureDetail(
  197. code="INPUT_SCOPE_MISMATCH",
  198. message="scope mismatch",
  199. disposition=FailureDisposition.REPLAN_TASK,
  200. source_tool="save_structured_script_candidate",
  201. )
  202. class Gateway:
  203. async def dispatch_script_tasks(self, *, task_ids, context):
  204. assert task_ids == ["compose-task", "other-task"]
  205. assert context["root_trace_id"] == "root"
  206. return [
  207. {
  208. "task_id": "compose-task",
  209. "task_status": "needs_replan",
  210. "attempt_id": "attempt-1",
  211. "failure": child_failure.to_dict(),
  212. },
  213. {"task_id": "other-task", "task_status": "completed"},
  214. ]
  215. registry = ToolRegistry()
  216. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  217. result = await registry._tools["dispatch_script_tasks"]["func"](
  218. task_ids=["compose-task", "other-task"],
  219. context={"root_trace_id": "root"},
  220. )
  221. assert "other-task" in result.output
  222. assert result.failure.code == "INPUT_SCOPE_MISMATCH"
  223. assert result.failure.source_tool == "save_structured_script_candidate"
  224. assert result.failure.details == {
  225. "attempt_id": "attempt-1",
  226. "task_id": "compose-task",
  227. }
  228. @pytest.mark.asyncio
  229. async def test_planner_breaks_after_same_dispatched_failure_twice(tmp_path):
  230. child_failure = FailureDetail(
  231. code="INPUT_SCOPE_MISMATCH",
  232. message="scope mismatch",
  233. disposition=FailureDisposition.REPLAN_TASK,
  234. source_tool="save_structured_script_candidate",
  235. )
  236. class Gateway:
  237. async def dispatch_script_tasks(self, *, task_ids, context):
  238. return [{
  239. "task_id": task_ids[0],
  240. "task_status": "needs_replan",
  241. "attempt_id": "volatile-attempt-id",
  242. "failure": child_failure.to_dict(),
  243. }]
  244. register_script_presets()
  245. registry = ToolRegistry()
  246. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  247. calls = 0
  248. async def llm_call(**_kwargs):
  249. nonlocal calls
  250. calls += 1
  251. return {
  252. "content": "",
  253. "tool_calls": [{
  254. "id": f"dispatch-{calls}",
  255. "type": "function",
  256. "function": {
  257. "name": "dispatch_script_tasks",
  258. "arguments": json.dumps({"task_ids": ["compose-task"]}),
  259. },
  260. }],
  261. "finish_reason": "tool_calls",
  262. }
  263. result = await AgentRunner(
  264. trace_store=FileSystemTraceStore(str(tmp_path)),
  265. tool_registry=registry,
  266. llm_call=llm_call,
  267. task_coordinator=_Coordinator(),
  268. ).run_result(
  269. [{"role": "user", "content": "plan"}],
  270. RunConfig(
  271. agent_type="script_planner",
  272. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  273. tools=["dispatch_script_tasks"],
  274. tool_groups=[],
  275. root_task_spec={
  276. "objective": "deliver script",
  277. "acceptance_criteria": [{"description": "script is accepted"}],
  278. },
  279. context={"root_trace_id": "root"},
  280. knowledge=_knowledge_off(),
  281. ),
  282. )
  283. assert calls == 2
  284. assert result["status"] == "incomplete"
  285. assert result["failure"]["code"] == "NO_PROGRESS"
  286. assert result["failure"]["details"]["last_failure"]["code"] == (
  287. "INPUT_SCOPE_MISMATCH"
  288. )