test_runner_completion_gate.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import pytest
  2. from agent.core.presets import AgentPreset, register_preset
  3. from agent.core.runner import AgentRunner, RunConfig
  4. from agent.failures import FailureDetail, FailureDisposition, ToolExecutionError
  5. from agent.orchestration.models import AgentRole, CompletionPolicy
  6. from agent.trace.models import Trace
  7. from agent.trace.store import FileSystemTraceStore
  8. from agent.tools.models import ToolCapability
  9. from agent.tools.registry import ToolRegistry
  10. class StubCoordinator:
  11. def __init__(self, snapshots):
  12. self.snapshots = list(snapshots)
  13. self.ensure_calls = []
  14. self.completion_calls = 0
  15. async def ensure_ledger(self, trace_id, mission, *, allow_create=True):
  16. self.ensure_calls.append((trace_id, mission))
  17. async def mission_completion(self, root_trace_id):
  18. self.completion_calls += 1
  19. if len(self.snapshots) > 1:
  20. return self.snapshots.pop(0)
  21. return self.snapshots[0]
  22. class RootFirstCoordinator(StubCoordinator):
  23. def __init__(self):
  24. super().__init__([{"status": "legacy"}])
  25. self.root_calls = 0
  26. async def root_completion(self, root_trace_id):
  27. self.root_calls += 1
  28. return {"status": "completed", "root_task_id": "root-task"}
  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.asyncio
  37. async def test_runner_prefers_root_completion_and_keeps_legacy_fallback(tmp_path):
  38. root_first = RootFirstCoordinator()
  39. runner = AgentRunner(
  40. trace_store=FileSystemTraceStore(str(tmp_path / "root-first")),
  41. llm_call=None,
  42. task_coordinator=root_first,
  43. )
  44. assert (await runner._get_mission_completion("root"))["status"] == "completed"
  45. assert root_first.root_calls == 1
  46. assert root_first.completion_calls == 0
  47. legacy = StubCoordinator([{"status": "blocked"}])
  48. runner.task_coordinator = legacy
  49. assert (await runner._get_mission_completion("root"))["status"] == "blocked"
  50. assert legacy.completion_calls == 1
  51. def _register_role_preset(name, role, max_iterations):
  52. register_preset(
  53. name,
  54. AgentPreset(
  55. role=role,
  56. allowed_tools=[],
  57. max_iterations=max_iterations,
  58. skills=[],
  59. ),
  60. )
  61. def _explicit_config(name, max_iterations):
  62. root_task_spec = {
  63. "objective": "complete the mission",
  64. "acceptance_criteria": [{"description": "mission result is validated"}],
  65. }
  66. return RunConfig(
  67. agent_type=name,
  68. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  69. max_iterations=max_iterations,
  70. tools=[],
  71. tool_groups=[],
  72. root_task_spec=root_task_spec,
  73. knowledge=_knowledge_off(),
  74. )
  75. @pytest.mark.asyncio
  76. async def test_planner_no_tool_exit_is_persisted_and_loop_continues(tmp_path):
  77. preset = "test_completion_gate_planner"
  78. _register_role_preset(preset, AgentRole.PLANNER, 3)
  79. coordinator = StubCoordinator([
  80. {
  81. "status": "needs_replan",
  82. "root_task_id": "root-task",
  83. "pending_tasks": [{"task_id": "child", "status": "pending"}],
  84. },
  85. {
  86. "status": "completed",
  87. "root_task_id": "root-task",
  88. "pending_tasks": [],
  89. "result_summary": "accepted root result",
  90. },
  91. ])
  92. calls = []
  93. async def llm_call(**kwargs):
  94. calls.append(kwargs["messages"])
  95. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  96. store = FileSystemTraceStore(str(tmp_path))
  97. runner = AgentRunner(
  98. trace_store=store,
  99. llm_call=llm_call,
  100. task_coordinator=coordinator,
  101. )
  102. events = [
  103. event
  104. async for event in runner.run(
  105. [{"role": "user", "content": "run mission"}],
  106. _explicit_config(preset, 3),
  107. )
  108. ]
  109. final = events[-1]
  110. assert isinstance(final, Trace)
  111. assert final.status == "completed"
  112. assert final.result_summary == "accepted root result"
  113. assert coordinator.ensure_calls[0][1]["objective"] == "complete the mission"
  114. assert len(calls) == 2
  115. assert any(
  116. message.get("role") == "user"
  117. and "[FRAMEWORK_MISSION_INCOMPLETE]" in message.get("content", "")
  118. for message in calls[1]
  119. )
  120. assert calls[1][0]["role"] == "system"
  121. assert "Framework role contract: Planner" in calls[1][0]["content"]
  122. persisted = await store.get_trace_messages(final.trace_id)
  123. controls = [
  124. message for message in persisted
  125. if message.role == "user"
  126. and "[FRAMEWORK_MISSION_INCOMPLETE]" in str(message.content)
  127. ]
  128. assert len(controls) == 1
  129. @pytest.mark.asyncio
  130. async def test_incomplete_planner_resume_restores_explicit_identity(tmp_path):
  131. preset = "test_completion_gate_resume"
  132. _register_role_preset(preset, AgentRole.PLANNER, 1)
  133. coordinator = StubCoordinator([{
  134. "status": "needs_replan",
  135. "root_task_id": "root-task",
  136. "pending_tasks": [{"task_id": "root-task", "status": "needs_replan"}],
  137. }])
  138. async def llm_call(**_kwargs):
  139. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  140. store = FileSystemTraceStore(str(tmp_path))
  141. runner = AgentRunner(
  142. trace_store=store,
  143. llm_call=llm_call,
  144. task_coordinator=coordinator,
  145. )
  146. first = await runner.run_result(
  147. [{"role": "user", "content": "run mission"}],
  148. _explicit_config(preset, 1),
  149. )
  150. assert first["status"] == "incomplete"
  151. resume_config = RunConfig(
  152. trace_id=first["trace_id"],
  153. knowledge=_knowledge_off(),
  154. )
  155. resumed = await runner.run_result([], resume_config)
  156. assert resumed["status"] == "incomplete"
  157. assert resumed["error"] == "iteration_limit_reached"
  158. assert resume_config.agent_type == preset
  159. assert resume_config.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION
  160. @pytest.mark.asyncio
  161. async def test_explicit_planner_rewind_and_wrong_role_resume_are_rejected_before_mutation(
  162. tmp_path,
  163. ):
  164. planner_preset = "test_completion_gate_rewind_planner"
  165. worker_preset = "test_completion_gate_wrong_role_worker"
  166. _register_role_preset(planner_preset, AgentRole.PLANNER, 2)
  167. _register_role_preset(worker_preset, AgentRole.WORKER, 2)
  168. store = FileSystemTraceStore(str(tmp_path))
  169. planner_trace = Trace(
  170. trace_id="planner-trace",
  171. mode="agent",
  172. task="mission",
  173. agent_type=planner_preset,
  174. agent_role=AgentRole.PLANNER.value,
  175. status="completed",
  176. head_sequence=0,
  177. last_sequence=10,
  178. )
  179. worker_trace = Trace(
  180. trace_id="worker-trace",
  181. mode="agent",
  182. task="worker",
  183. agent_type=worker_preset,
  184. agent_role=AgentRole.WORKER.value,
  185. status="completed",
  186. )
  187. await store.create_trace(planner_trace)
  188. await store.create_trace(worker_trace)
  189. coordinator = StubCoordinator([{"status": "completed"}])
  190. runner = AgentRunner(
  191. trace_store=store,
  192. llm_call=lambda **_kwargs: None,
  193. task_coordinator=coordinator,
  194. )
  195. with pytest.raises(ValueError, match="do not support rewind"):
  196. await runner.run_result(
  197. [],
  198. RunConfig(
  199. trace_id=planner_trace.trace_id,
  200. agent_type=planner_preset,
  201. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  202. after_sequence=5,
  203. ),
  204. )
  205. with pytest.raises(ValueError, match="Trace role mismatch"):
  206. await runner.run_result(
  207. [],
  208. RunConfig(
  209. trace_id=worker_trace.trace_id,
  210. agent_type=planner_preset,
  211. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  212. ),
  213. )
  214. assert (await store.get_trace(planner_trace.trace_id)).status == "completed"
  215. assert (await store.get_trace(worker_trace.trace_id)).status == "completed"
  216. assert coordinator.ensure_calls == []
  217. @pytest.mark.asyncio
  218. async def test_planner_iteration_exhaustion_is_incomplete(tmp_path):
  219. preset = "test_completion_gate_exhaustion"
  220. _register_role_preset(preset, AgentRole.PLANNER, 2)
  221. coordinator = StubCoordinator([{
  222. "status": "waiting_children",
  223. "root_task_id": "root-task",
  224. "pending_tasks": ["child"],
  225. }])
  226. async def llm_call(**_kwargs):
  227. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  228. runner = AgentRunner(
  229. trace_store=FileSystemTraceStore(str(tmp_path)),
  230. llm_call=llm_call,
  231. task_coordinator=coordinator,
  232. )
  233. result = await runner.run_result(
  234. [{"role": "user", "content": "run mission"}],
  235. _explicit_config(preset, 2),
  236. )
  237. assert result["status"] == "incomplete"
  238. assert result["error"] == "iteration_limit_reached"
  239. assert result["summary"] is None
  240. @pytest.mark.asyncio
  241. async def test_blocked_root_stops_planner_as_incomplete(tmp_path):
  242. preset = "test_completion_gate_blocked"
  243. _register_role_preset(preset, AgentRole.PLANNER, 3)
  244. coordinator = StubCoordinator([{
  245. "status": "blocked",
  246. "root_task_id": "root-task",
  247. "blocked_reason": "external dependency",
  248. "pending_tasks": [],
  249. }])
  250. calls = 0
  251. async def llm_call(**_kwargs):
  252. nonlocal calls
  253. calls += 1
  254. return {"content": "blocked", "tool_calls": None, "finish_reason": "stop"}
  255. runner = AgentRunner(
  256. trace_store=FileSystemTraceStore(str(tmp_path)),
  257. llm_call=llm_call,
  258. task_coordinator=coordinator,
  259. )
  260. result = await runner.run_result(
  261. [{"role": "user", "content": "run mission"}],
  262. _explicit_config(preset, 3),
  263. )
  264. assert calls == 1
  265. assert result["status"] == "incomplete"
  266. assert result["error"] == "mission_blocked"
  267. @pytest.mark.asyncio
  268. @pytest.mark.parametrize("role", [AgentRole.WORKER, AgentRole.VALIDATOR])
  269. async def test_explicit_subtrace_without_terminal_submit_is_protocol_failure(
  270. tmp_path, role
  271. ):
  272. preset = "test_completion_gate_" + role.value
  273. _register_role_preset(preset, role, 2)
  274. async def llm_call(**_kwargs):
  275. return {"content": "done", "tool_calls": None, "finish_reason": "stop"}
  276. runner = AgentRunner(
  277. trace_store=FileSystemTraceStore(str(tmp_path / role.value)),
  278. llm_call=llm_call,
  279. task_coordinator=StubCoordinator([{"status": "completed"}]),
  280. )
  281. result = await runner.run_result(
  282. [{"role": "user", "content": "sub task"}],
  283. _explicit_config(preset, 2),
  284. )
  285. assert result["status"] == "failed"
  286. assert result["error"] == "protocol_violation"
  287. assert result["failure"]["code"] == "PROTOCOL_VIOLATION"
  288. @pytest.mark.asyncio
  289. async def test_worker_replan_failure_terminates_without_protocol_overwrite(tmp_path):
  290. preset = "test_structured_failure_worker"
  291. register_preset(
  292. preset,
  293. AgentPreset(
  294. role=AgentRole.WORKER,
  295. allowed_tools=["reject_contract"],
  296. max_iterations=4,
  297. skills=[],
  298. ),
  299. )
  300. registry = ToolRegistry()
  301. async def reject_contract() -> str:
  302. raise ToolExecutionError(
  303. FailureDetail(
  304. code="INPUT_SCOPE_MISMATCH",
  305. message="Paragraph has no covering Structure",
  306. disposition=FailureDisposition.REPLAN_TASK,
  307. )
  308. )
  309. registry.register(
  310. reject_contract,
  311. capabilities=[ToolCapability.READ],
  312. )
  313. calls = 0
  314. async def llm_call(**_kwargs):
  315. nonlocal calls
  316. calls += 1
  317. return {
  318. "content": "",
  319. "tool_calls": [
  320. {
  321. "id": "call-reject",
  322. "type": "function",
  323. "function": {"name": "reject_contract", "arguments": "{}"},
  324. }
  325. ],
  326. "finish_reason": "tool_calls",
  327. }
  328. store = FileSystemTraceStore(str(tmp_path))
  329. runner = AgentRunner(
  330. trace_store=store,
  331. tool_registry=registry,
  332. llm_call=llm_call,
  333. task_coordinator=StubCoordinator([{"status": "completed"}]),
  334. )
  335. config = _explicit_config(preset, 4)
  336. config.tools = ["reject_contract"]
  337. result = await runner.run_result([{"role": "user", "content": "work"}], config)
  338. assert calls == 1
  339. assert result["status"] == "failed"
  340. assert result["error"].startswith("INPUT_SCOPE_MISMATCH:")
  341. assert result["failure"]["code"] == "INPUT_SCOPE_MISMATCH"
  342. trace = await store.get_trace(result["trace_id"])
  343. assert trace.failure.code == "INPUT_SCOPE_MISMATCH"
  344. @pytest.mark.asyncio
  345. async def test_planner_observes_replan_failure_and_keeps_control(tmp_path):
  346. preset = "test_structured_failure_planner"
  347. register_preset(
  348. preset,
  349. AgentPreset(
  350. role=AgentRole.PLANNER,
  351. allowed_tools=["reject_contract"],
  352. max_iterations=3,
  353. skills=[],
  354. ),
  355. )
  356. registry = ToolRegistry()
  357. async def reject_contract() -> str:
  358. raise ToolExecutionError(
  359. FailureDetail(
  360. code="TASK_CONTRACT_NOT_EXECUTABLE",
  361. message="revise the frozen contract",
  362. disposition=FailureDisposition.REPLAN_TASK,
  363. )
  364. )
  365. registry.register(reject_contract, capabilities=[ToolCapability.TASK_CONTROL])
  366. calls = 0
  367. async def llm_call(**_kwargs):
  368. nonlocal calls
  369. calls += 1
  370. if calls == 1:
  371. return {
  372. "content": "",
  373. "tool_calls": [
  374. {
  375. "id": "call-reject",
  376. "type": "function",
  377. "function": {"name": "reject_contract", "arguments": "{}"},
  378. }
  379. ],
  380. "finish_reason": "tool_calls",
  381. }
  382. return {"content": "replanned", "tool_calls": None, "finish_reason": "stop"}
  383. config = _explicit_config(preset, 3)
  384. config.tools = ["reject_contract"]
  385. result = await AgentRunner(
  386. trace_store=FileSystemTraceStore(str(tmp_path)),
  387. tool_registry=registry,
  388. llm_call=llm_call,
  389. task_coordinator=StubCoordinator([{"status": "completed"}]),
  390. ).run_result([{"role": "user", "content": "plan"}], config)
  391. assert calls == 2
  392. assert result["status"] == "completed"
  393. assert result["failure"] is None