test_tool_failure_bridge.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. from __future__ import annotations
  2. import json
  3. import pytest
  4. from agent import (
  5. AgentRunner,
  6. FailureDetail,
  7. FailureDisposition,
  8. RunConfig,
  9. ToolExecutionError,
  10. ToolRegistry,
  11. )
  12. from agent.orchestration import CompletionPolicy, DeterministicWorkerContext
  13. from agent.trace.store import FileSystemTraceStore
  14. from script_build_host.agents.presets import register_script_presets
  15. from script_build_host.application.phase_two_candidates import PhaseTwoCandidateError
  16. from script_build_host.application.workbench_workers import ScriptBuildDeterministicWorker
  17. from script_build_host.domain.errors import (
  18. ArtifactDigestMismatch,
  19. MissionFencingTokenStale,
  20. PublicationLockTimeout,
  21. ScriptBuildError,
  22. )
  23. from script_build_host.domain.task_contracts import TaskContractError
  24. from script_build_host.tools.failures import classify_script_tool_failure
  25. from script_build_host.tools.registry import register_script_tools
  26. class _Coordinator:
  27. async def ensure_ledger(self, *_args, **_kwargs):
  28. return None
  29. def _knowledge_off():
  30. from agent.tools.builtin.knowledge import KnowledgeConfig
  31. return KnowledgeConfig(
  32. enable_extraction=False,
  33. enable_completion_extraction=False,
  34. enable_injection=False,
  35. )
  36. @pytest.mark.parametrize(
  37. ("error", "expected"),
  38. [
  39. (
  40. PhaseTwoCandidateError("INPUT_SCOPE_MISMATCH", "scope is invalid"),
  41. FailureDisposition.REPLAN_TASK,
  42. ),
  43. (
  44. PhaseTwoCandidateError("GOAL_COVERAGE_INCOMPLETE", "goal is missing"),
  45. FailureDisposition.REPLAN_TASK,
  46. ),
  47. (
  48. ScriptBuildError("TASK_KIND_PRESET_MISMATCH", "Task kind cannot change"),
  49. FailureDisposition.REPLAN_TASK,
  50. ),
  51. (
  52. PhaseTwoCandidateError("LEGACY_WRITE_INVALID", "workspace is invalid"),
  53. FailureDisposition.REPAIR_ATTEMPT,
  54. ),
  55. (PublicationLockTimeout(), FailureDisposition.RETRY_CALL),
  56. (MissionFencingTokenStale(), FailureDisposition.ABORT_RUN),
  57. (ArtifactDigestMismatch(), FailureDisposition.ABORT_RUN),
  58. (
  59. ScriptBuildError("UNCLASSIFIED_DOMAIN_FAILURE", "fail closed"),
  60. FailureDisposition.ABORT_RUN,
  61. ),
  62. (ValueError("bad argument"), FailureDisposition.RETRY_CALL),
  63. ],
  64. )
  65. def test_script_failure_classification_is_central_and_fail_closed(error, expected):
  66. failure = classify_script_tool_failure(
  67. error,
  68. source_tool="test_tool",
  69. context={"task_id": "task-1", "attempt_id": "attempt-1"},
  70. )
  71. assert failure.disposition is expected
  72. assert failure.source_tool == "test_tool"
  73. assert failure.details["task_id"] == "task-1"
  74. def test_phase_policy_failures_keep_semantic_details_in_their_fingerprint():
  75. structure = classify_script_tool_failure(
  76. TaskContractError(
  77. "PHASE_POLICY_VIOLATION",
  78. "wrong structure placement",
  79. details={"attempted_task_kinds": ["structure"]},
  80. ),
  81. source_tool="plan_script_tasks",
  82. )
  83. paragraph = classify_script_tool_failure(
  84. TaskContractError(
  85. "PHASE_POLICY_VIOLATION",
  86. "wrong paragraph placement",
  87. details={"attempted_task_kinds": ["paragraph"]},
  88. ),
  89. source_tool="plan_script_tasks",
  90. )
  91. assert structure.details["attempted_task_kinds"] == ["structure"]
  92. assert structure.fingerprint() != paragraph.fingerprint()
  93. def test_paragraph_batch_tool_exposes_exact_nested_contract():
  94. registry = ToolRegistry()
  95. register_script_tools(registry, object()) # type: ignore[arg-type]
  96. schema = registry._tools["save_script_paragraphs"]["schema"]
  97. item = schema["function"]["parameters"]["properties"]["paragraphs"]["items"]
  98. assert {entry["required"][0] for entry in item["oneOf"]} == {
  99. "client_key",
  100. "paragraph_target_key",
  101. }
  102. assert "index" not in item["properties"]
  103. assert item["properties"]["paragraph_index"]["minimum"] == 1
  104. assert item["properties"]["theme"]["type"] == "string"
  105. assert item["properties"]["theme_elements"]["type"] == "array"
  106. assert "paragraph_id" not in item["properties"]
  107. assert item["additionalProperties"] is False
  108. def test_strategy_tool_accepts_only_workbench_handles():
  109. registry = ToolRegistry()
  110. register_script_tools(registry, object()) # type: ignore[arg-type]
  111. schema = registry._tools["load_frozen_strategy"]["schema"]
  112. strategy_ref = schema["function"]["parameters"]["properties"]["strategy_handle"]
  113. assert strategy_ref["pattern"] == "^strategy_[0-9a-f]{20}$"
  114. def test_element_tool_exposes_only_semantic_targets_and_dimension_contracts():
  115. registry = ToolRegistry()
  116. register_script_tools(registry, object()) # type: ignore[arg-type]
  117. parameters = registry._tools["save_script_elements"]["schema"]["function"]["parameters"]
  118. element = parameters["properties"]["elements"]["items"]
  119. assert element["properties"]["dimension_primary"]["enum"] == ["实质", "形式"]
  120. assert "element_id" not in element["properties"]
  121. links = parameters["properties"]["links"]["items"]
  122. assert links["properties"]["paragraph_target_key"]["pattern"].startswith("^pt_")
  123. assert "paragraph_id" not in links["properties"]
  124. assert "element_ids" not in links["properties"]
  125. assert links["additionalProperties"] is False
  126. @pytest.mark.asyncio
  127. async def test_compose_scope_failure_exits_worker_and_keeps_exact_domain_error(tmp_path):
  128. del tmp_path
  129. class Candidates:
  130. async def resolve_attempt_manifest(self, *, context):
  131. del context
  132. raise PhaseTwoCandidateError("ARTIFACT_NOT_FOUND", "not frozen")
  133. async def save_structured_script_candidate(self, *, acceptance_notes, context):
  134. del acceptance_notes, context
  135. raise PhaseTwoCandidateError(
  136. "INPUT_SCOPE_MISMATCH",
  137. "Paragraph has no adopted covering Structure",
  138. )
  139. worker = ScriptBuildDeterministicWorker(Candidates())
  140. context = DeterministicWorkerContext(
  141. root_trace_id="root",
  142. task=type(
  143. "Task",
  144. (),
  145. {
  146. "task_id": "compose-task",
  147. "current_spec": type(
  148. "Spec", (), {"context_refs": ("script-build://task-kinds/compose",)}
  149. )(),
  150. "current_spec_version": 1,
  151. },
  152. )(),
  153. attempt=type("Attempt", (), {"attempt_id": "attempt-1"})(),
  154. ledger_revision=1,
  155. accepted_child_results=(),
  156. operation_id=None,
  157. execution_epoch=0,
  158. role_context={},
  159. )
  160. with pytest.raises(ToolExecutionError) as captured:
  161. await worker.execute(context)
  162. assert captured.value.failure.to_dict() == {
  163. "code": "INPUT_SCOPE_MISMATCH",
  164. "message": "Paragraph has no adopted covering Structure",
  165. "disposition": "replan_task",
  166. "source_tool": "deterministic_compose",
  167. "details": {"attempt_id": "attempt-1", "task_id": "compose-task"},
  168. }
  169. @pytest.mark.asyncio
  170. async def test_workspace_error_can_be_repaired_in_same_attempt(tmp_path):
  171. class Gateway:
  172. def __init__(self):
  173. self.write_calls = 0
  174. async def candidate_command(self, name, _payload, _context):
  175. assert name == "save_script_paragraphs"
  176. self.write_calls += 1
  177. if self.write_calls == 1:
  178. raise PhaseTwoCandidateError(
  179. "LEGACY_WRITE_INVALID",
  180. "paragraph dimension is duplicated",
  181. )
  182. return {"created": True}
  183. async def submit_current_attempt(self, _context):
  184. return {"attempt_id": "attempt-1", "status": "awaiting_validation"}
  185. register_script_presets()
  186. gateway = Gateway()
  187. registry = ToolRegistry()
  188. register_script_tools(registry, gateway) # type: ignore[arg-type]
  189. calls = 0
  190. async def llm_call(**_kwargs):
  191. nonlocal calls
  192. calls += 1
  193. if calls <= 2:
  194. tool_name = "save_script_paragraphs"
  195. arguments = json.dumps(
  196. {
  197. "paragraphs": [
  198. {
  199. "client_key": "opening",
  200. "paragraph_index": 1,
  201. "name": "opening",
  202. "content_range": {"start": 0, "end": 100},
  203. "level": 1,
  204. "theme_elements": [
  205. {"原子点": "theme", "维度": "topic", "维度类型": "主维度"}
  206. ],
  207. "form_elements": [
  208. {"原子点": "form", "维度": "contrast", "维度类型": "主维度"}
  209. ],
  210. "function_elements": [
  211. {"原子点": "hook", "维度": "role", "维度类型": "主维度"}
  212. ],
  213. "feeling_elements": [{"原子点": "curious", "维度": "tone"}],
  214. "theme": "A concrete opening theme",
  215. "form": "A visible contrast",
  216. "function": "Hooks the audience",
  217. "feeling": "Creates curiosity",
  218. "description": "A complete opening paragraph",
  219. "full_description": "The audience sees the concrete opening copy.",
  220. }
  221. ],
  222. "expected_state_revision": "revision-1",
  223. }
  224. )
  225. else:
  226. tool_name = "submit_attempt"
  227. arguments = "{}"
  228. return {
  229. "content": "",
  230. "tool_calls": [
  231. {
  232. "id": f"call-{calls}",
  233. "type": "function",
  234. "function": {"name": tool_name, "arguments": arguments},
  235. }
  236. ],
  237. "finish_reason": "tool_calls",
  238. }
  239. result = await AgentRunner(
  240. trace_store=FileSystemTraceStore(str(tmp_path)),
  241. tool_registry=registry,
  242. llm_call=llm_call,
  243. task_coordinator=_Coordinator(),
  244. ).run_result(
  245. [{"role": "user", "content": "write paragraph"}],
  246. RunConfig(
  247. agent_type="script_paragraph_worker",
  248. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  249. tools=["save_script_paragraphs", "submit_attempt"],
  250. tool_groups=[],
  251. context={"task_id": "paragraph-task", "attempt_id": "attempt-1"},
  252. knowledge=_knowledge_off(),
  253. ),
  254. )
  255. assert gateway.write_calls == 2
  256. assert calls == 3
  257. assert result["status"] == "completed"
  258. assert result["failure"] is None
  259. @pytest.mark.asyncio
  260. async def test_dispatch_preserves_batch_and_surfaces_child_failure():
  261. child_failure = FailureDetail(
  262. code="INPUT_SCOPE_MISMATCH",
  263. message="scope mismatch",
  264. disposition=FailureDisposition.REPLAN_TASK,
  265. source_tool="save_structured_script_candidate",
  266. )
  267. class Gateway:
  268. async def dispatch_script_tasks(self, *, task_ids, context):
  269. assert task_ids == ["compose-task", "other-task"]
  270. assert context["root_trace_id"] == "root"
  271. return [
  272. {
  273. "task_id": "compose-task",
  274. "task_status": "needs_replan",
  275. "attempt_id": "attempt-1",
  276. "failure": child_failure.to_dict(),
  277. },
  278. {"task_id": "other-task", "task_status": "completed"},
  279. ]
  280. registry = ToolRegistry()
  281. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  282. result = await registry._tools["dispatch_script_tasks"]["func"](
  283. task_ids=["compose-task", "other-task"],
  284. context={"root_trace_id": "root"},
  285. )
  286. assert "other-task" in result.output
  287. assert result.failure.code == "INPUT_SCOPE_MISMATCH"
  288. assert result.failure.source_tool == "save_structured_script_candidate"
  289. assert result.failure.details == {
  290. "attempt_id": "attempt-1",
  291. "task_id": "compose-task",
  292. }
  293. @pytest.mark.asyncio
  294. async def test_dispatch_converts_child_no_progress_abort_into_planner_replan():
  295. child_failure = FailureDetail(
  296. code="NO_PROGRESS",
  297. message="worker repeated the same invalid write",
  298. disposition=FailureDisposition.ABORT_RUN,
  299. source_tool="create_script_paragraphs",
  300. details={"reason": "same_failure_repeated"},
  301. )
  302. class Gateway:
  303. async def dispatch_script_tasks(self, *, task_ids, context):
  304. return [
  305. {
  306. "task_id": task_ids[0],
  307. "task_status": "needs_replan",
  308. "attempt_id": "attempt-1",
  309. "failure": child_failure.to_dict(),
  310. }
  311. ]
  312. registry = ToolRegistry()
  313. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  314. result = await registry._tools["dispatch_script_tasks"]["func"](
  315. task_ids=["structure-task"],
  316. context={"root_trace_id": "root"},
  317. )
  318. assert result.failure.code == "NO_PROGRESS"
  319. assert result.failure.disposition is FailureDisposition.REPLAN_TASK
  320. assert result.failure.source_tool == "dispatch_script_tasks"
  321. assert result.failure.details["child_disposition"] == "abort_run"
  322. assert result.failure.details["child_source_tool"] == "create_script_paragraphs"
  323. @pytest.mark.asyncio
  324. async def test_planner_breaks_after_same_dispatched_failure_twice(tmp_path):
  325. child_failure = FailureDetail(
  326. code="INPUT_SCOPE_MISMATCH",
  327. message="scope mismatch",
  328. disposition=FailureDisposition.REPLAN_TASK,
  329. source_tool="save_structured_script_candidate",
  330. )
  331. class Gateway:
  332. async def dispatch_script_tasks(self, *, task_ids, context):
  333. return [
  334. {
  335. "task_id": task_ids[0],
  336. "task_status": "needs_replan",
  337. "attempt_id": "volatile-attempt-id",
  338. "failure": child_failure.to_dict(),
  339. }
  340. ]
  341. register_script_presets()
  342. registry = ToolRegistry()
  343. register_script_tools(registry, Gateway()) # type: ignore[arg-type]
  344. calls = 0
  345. async def llm_call(**_kwargs):
  346. nonlocal calls
  347. calls += 1
  348. return {
  349. "content": "",
  350. "tool_calls": [
  351. {
  352. "id": f"dispatch-{calls}",
  353. "type": "function",
  354. "function": {
  355. "name": "dispatch_script_tasks",
  356. "arguments": json.dumps({"task_ids": ["compose-task"]}),
  357. },
  358. }
  359. ],
  360. "finish_reason": "tool_calls",
  361. }
  362. result = await AgentRunner(
  363. trace_store=FileSystemTraceStore(str(tmp_path)),
  364. tool_registry=registry,
  365. llm_call=llm_call,
  366. task_coordinator=_Coordinator(),
  367. ).run_result(
  368. [{"role": "user", "content": "plan"}],
  369. RunConfig(
  370. agent_type="script_planner",
  371. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  372. tools=["dispatch_script_tasks"],
  373. tool_groups=[],
  374. root_task_spec={
  375. "objective": "deliver script",
  376. "acceptance_criteria": [{"description": "script is accepted"}],
  377. },
  378. context={"root_trace_id": "root"},
  379. knowledge=_knowledge_off(),
  380. ),
  381. )
  382. assert calls == 2
  383. assert result["status"] == "incomplete"
  384. assert result["failure"]["code"] == "NO_PROGRESS"
  385. assert result["failure"]["details"]["last_failure"]["code"] == ("INPUT_SCOPE_MISMATCH")