test_tool_failure_bridge.py 17 KB

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