test_tool_failure_bridge.py 16 KB

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