test_runner_completion_gate.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import pytest
  2. from agent.core.presets import AgentPreset, register_preset
  3. from agent.core.runner import AgentRunner, RunConfig
  4. from agent.orchestration.models import AgentRole, CompletionPolicy
  5. from agent.trace.models import Trace
  6. from agent.trace.store import FileSystemTraceStore
  7. class StubCoordinator:
  8. def __init__(self, snapshots):
  9. self.snapshots = list(snapshots)
  10. self.ensure_calls = []
  11. self.completion_calls = 0
  12. async def ensure_ledger(self, trace_id, mission, *, allow_create=True):
  13. self.ensure_calls.append((trace_id, mission))
  14. async def mission_completion(self, root_trace_id):
  15. self.completion_calls += 1
  16. if len(self.snapshots) > 1:
  17. return self.snapshots.pop(0)
  18. return self.snapshots[0]
  19. class RootFirstCoordinator(StubCoordinator):
  20. def __init__(self):
  21. super().__init__([{"status": "legacy"}])
  22. self.root_calls = 0
  23. async def root_completion(self, root_trace_id):
  24. self.root_calls += 1
  25. return {"status": "completed", "root_task_id": "root-task"}
  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.asyncio
  34. async def test_runner_prefers_root_completion_and_keeps_legacy_fallback(tmp_path):
  35. root_first = RootFirstCoordinator()
  36. runner = AgentRunner(
  37. trace_store=FileSystemTraceStore(str(tmp_path / "root-first")),
  38. llm_call=None,
  39. task_coordinator=root_first,
  40. )
  41. assert (await runner._get_mission_completion("root"))["status"] == "completed"
  42. assert root_first.root_calls == 1
  43. assert root_first.completion_calls == 0
  44. legacy = StubCoordinator([{"status": "blocked"}])
  45. runner.task_coordinator = legacy
  46. assert (await runner._get_mission_completion("root"))["status"] == "blocked"
  47. assert legacy.completion_calls == 1
  48. def _register_role_preset(name, role, max_iterations):
  49. register_preset(
  50. name,
  51. AgentPreset(
  52. role=role,
  53. allowed_tools=[],
  54. max_iterations=max_iterations,
  55. skills=[],
  56. ),
  57. )
  58. def _explicit_config(name, max_iterations):
  59. root_task_spec = {
  60. "objective": "complete the mission",
  61. "acceptance_criteria": [{"description": "mission result is validated"}],
  62. }
  63. return RunConfig(
  64. agent_type=name,
  65. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  66. max_iterations=max_iterations,
  67. tools=[],
  68. tool_groups=[],
  69. root_task_spec=root_task_spec,
  70. knowledge=_knowledge_off(),
  71. )
  72. @pytest.mark.asyncio
  73. async def test_planner_no_tool_exit_is_persisted_and_loop_continues(tmp_path):
  74. preset = "test_completion_gate_planner"
  75. _register_role_preset(preset, AgentRole.PLANNER, 3)
  76. coordinator = StubCoordinator([
  77. {
  78. "status": "needs_replan",
  79. "root_task_id": "root-task",
  80. "pending_tasks": [{"task_id": "child", "status": "pending"}],
  81. },
  82. {
  83. "status": "completed",
  84. "root_task_id": "root-task",
  85. "pending_tasks": [],
  86. "result_summary": "accepted root result",
  87. },
  88. ])
  89. calls = []
  90. async def llm_call(**kwargs):
  91. calls.append(kwargs["messages"])
  92. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  93. store = FileSystemTraceStore(str(tmp_path))
  94. runner = AgentRunner(
  95. trace_store=store,
  96. llm_call=llm_call,
  97. task_coordinator=coordinator,
  98. )
  99. events = [
  100. event
  101. async for event in runner.run(
  102. [{"role": "user", "content": "run mission"}],
  103. _explicit_config(preset, 3),
  104. )
  105. ]
  106. final = events[-1]
  107. assert isinstance(final, Trace)
  108. assert final.status == "completed"
  109. assert final.result_summary == "accepted root result"
  110. assert coordinator.ensure_calls[0][1]["objective"] == "complete the mission"
  111. assert len(calls) == 2
  112. assert any(
  113. message.get("role") == "user"
  114. and "[FRAMEWORK_MISSION_INCOMPLETE]" in message.get("content", "")
  115. for message in calls[1]
  116. )
  117. assert calls[1][0]["role"] == "system"
  118. assert "Framework role contract: Planner" in calls[1][0]["content"]
  119. persisted = await store.get_trace_messages(final.trace_id)
  120. controls = [
  121. message for message in persisted
  122. if message.role == "user"
  123. and "[FRAMEWORK_MISSION_INCOMPLETE]" in str(message.content)
  124. ]
  125. assert len(controls) == 1
  126. @pytest.mark.asyncio
  127. async def test_incomplete_planner_resume_restores_explicit_identity(tmp_path):
  128. preset = "test_completion_gate_resume"
  129. _register_role_preset(preset, AgentRole.PLANNER, 1)
  130. coordinator = StubCoordinator([{
  131. "status": "needs_replan",
  132. "root_task_id": "root-task",
  133. "pending_tasks": [{"task_id": "root-task", "status": "needs_replan"}],
  134. }])
  135. async def llm_call(**_kwargs):
  136. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  137. store = FileSystemTraceStore(str(tmp_path))
  138. runner = AgentRunner(
  139. trace_store=store,
  140. llm_call=llm_call,
  141. task_coordinator=coordinator,
  142. )
  143. first = await runner.run_result(
  144. [{"role": "user", "content": "run mission"}],
  145. _explicit_config(preset, 1),
  146. )
  147. assert first["status"] == "incomplete"
  148. resume_config = RunConfig(
  149. trace_id=first["trace_id"],
  150. knowledge=_knowledge_off(),
  151. )
  152. resumed = await runner.run_result([], resume_config)
  153. assert resumed["status"] == "incomplete"
  154. assert resumed["error"] == "iteration_limit_reached"
  155. assert resume_config.agent_type == preset
  156. assert resume_config.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION
  157. @pytest.mark.asyncio
  158. async def test_explicit_planner_rewind_and_wrong_role_resume_are_rejected_before_mutation(
  159. tmp_path,
  160. ):
  161. planner_preset = "test_completion_gate_rewind_planner"
  162. worker_preset = "test_completion_gate_wrong_role_worker"
  163. _register_role_preset(planner_preset, AgentRole.PLANNER, 2)
  164. _register_role_preset(worker_preset, AgentRole.WORKER, 2)
  165. store = FileSystemTraceStore(str(tmp_path))
  166. planner_trace = Trace(
  167. trace_id="planner-trace",
  168. mode="agent",
  169. task="mission",
  170. agent_type=planner_preset,
  171. agent_role=AgentRole.PLANNER.value,
  172. status="completed",
  173. head_sequence=0,
  174. last_sequence=10,
  175. )
  176. worker_trace = Trace(
  177. trace_id="worker-trace",
  178. mode="agent",
  179. task="worker",
  180. agent_type=worker_preset,
  181. agent_role=AgentRole.WORKER.value,
  182. status="completed",
  183. )
  184. await store.create_trace(planner_trace)
  185. await store.create_trace(worker_trace)
  186. coordinator = StubCoordinator([{"status": "completed"}])
  187. runner = AgentRunner(
  188. trace_store=store,
  189. llm_call=lambda **_kwargs: None,
  190. task_coordinator=coordinator,
  191. )
  192. with pytest.raises(ValueError, match="do not support rewind"):
  193. await runner.run_result(
  194. [],
  195. RunConfig(
  196. trace_id=planner_trace.trace_id,
  197. agent_type=planner_preset,
  198. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  199. after_sequence=5,
  200. ),
  201. )
  202. with pytest.raises(ValueError, match="Trace role mismatch"):
  203. await runner.run_result(
  204. [],
  205. RunConfig(
  206. trace_id=worker_trace.trace_id,
  207. agent_type=planner_preset,
  208. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  209. ),
  210. )
  211. assert (await store.get_trace(planner_trace.trace_id)).status == "completed"
  212. assert (await store.get_trace(worker_trace.trace_id)).status == "completed"
  213. assert coordinator.ensure_calls == []
  214. @pytest.mark.asyncio
  215. async def test_planner_iteration_exhaustion_is_incomplete(tmp_path):
  216. preset = "test_completion_gate_exhaustion"
  217. _register_role_preset(preset, AgentRole.PLANNER, 2)
  218. coordinator = StubCoordinator([{
  219. "status": "waiting_children",
  220. "root_task_id": "root-task",
  221. "pending_tasks": ["child"],
  222. }])
  223. async def llm_call(**_kwargs):
  224. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  225. runner = AgentRunner(
  226. trace_store=FileSystemTraceStore(str(tmp_path)),
  227. llm_call=llm_call,
  228. task_coordinator=coordinator,
  229. )
  230. result = await runner.run_result(
  231. [{"role": "user", "content": "run mission"}],
  232. _explicit_config(preset, 2),
  233. )
  234. assert result["status"] == "incomplete"
  235. assert result["error"] == "iteration_limit_reached"
  236. assert result["summary"] is None
  237. @pytest.mark.asyncio
  238. async def test_blocked_root_stops_planner_as_incomplete(tmp_path):
  239. preset = "test_completion_gate_blocked"
  240. _register_role_preset(preset, AgentRole.PLANNER, 3)
  241. coordinator = StubCoordinator([{
  242. "status": "blocked",
  243. "root_task_id": "root-task",
  244. "blocked_reason": "external dependency",
  245. "pending_tasks": [],
  246. }])
  247. calls = 0
  248. async def llm_call(**_kwargs):
  249. nonlocal calls
  250. calls += 1
  251. return {"content": "blocked", "tool_calls": None, "finish_reason": "stop"}
  252. runner = AgentRunner(
  253. trace_store=FileSystemTraceStore(str(tmp_path)),
  254. llm_call=llm_call,
  255. task_coordinator=coordinator,
  256. )
  257. result = await runner.run_result(
  258. [{"role": "user", "content": "run mission"}],
  259. _explicit_config(preset, 3),
  260. )
  261. assert calls == 1
  262. assert result["status"] == "incomplete"
  263. assert result["error"] == "mission_blocked"
  264. @pytest.mark.asyncio
  265. @pytest.mark.parametrize("role", [AgentRole.WORKER, AgentRole.VALIDATOR])
  266. async def test_explicit_subtrace_without_terminal_submit_is_protocol_failure(
  267. tmp_path, role
  268. ):
  269. preset = "test_completion_gate_" + role.value
  270. _register_role_preset(preset, role, 2)
  271. async def llm_call(**_kwargs):
  272. return {"content": "done", "tool_calls": None, "finish_reason": "stop"}
  273. runner = AgentRunner(
  274. trace_store=FileSystemTraceStore(str(tmp_path / role.value)),
  275. llm_call=llm_call,
  276. task_coordinator=StubCoordinator([{"status": "completed"}]),
  277. )
  278. result = await runner.run_result(
  279. [{"role": "user", "content": "sub task"}],
  280. _explicit_config(preset, 2),
  281. )
  282. assert result["status"] == "failed"
  283. assert result["error"] == "protocol_violation"