test_mission_loop.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """End-to-end coverage for the explicit Planner mission loop."""
  2. import json
  3. import pytest
  4. from agent.core.runner import AgentRunner, RunConfig
  5. from agent.orchestration.models import (
  6. AgentRole,
  7. CompletionPolicy,
  8. TaskStatus,
  9. ValidationVerdict,
  10. )
  11. from agent.orchestration.store import FileSystemArtifactStore, FileSystemTaskStore
  12. from agent.orchestration.wiring import wire_orchestration
  13. from agent.tools.builtin.knowledge import KnowledgeConfig
  14. from agent.trace.store import FileSystemTraceStore
  15. ROOT_OBJECTIVE = "complete the generic root"
  16. ROOT_OBJECTIVE_V2 = "complete the revised generic root"
  17. CHILD_OBJECTIVE_V1 = "produce the generic unit"
  18. CHILD_OBJECTIVE_V2 = "produce the revised generic unit"
  19. def _tool_call(call_id, name, arguments):
  20. return {
  21. "content": "",
  22. "tool_calls": [{
  23. "id": call_id,
  24. "type": "function",
  25. "function": {
  26. "name": name,
  27. "arguments": json.dumps(arguments),
  28. },
  29. }],
  30. "finish_reason": "tool_calls",
  31. }
  32. def _no_tool():
  33. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  34. def _latest_json_user_prompt(messages):
  35. for message in reversed(messages):
  36. if message.get("role") != "user":
  37. continue
  38. content = message.get("content")
  39. if not isinstance(content, str):
  40. continue
  41. try:
  42. value = json.loads(content)
  43. except (TypeError, ValueError):
  44. continue
  45. if isinstance(value, dict) and "task_spec" in value:
  46. return value
  47. raise AssertionError("role sub-trace did not receive its structured task prompt")
  48. class MissionLoopLLM:
  49. """Choose actions from role tools and durable Ledger state."""
  50. def __init__(self, task_store):
  51. self.task_store = task_store
  52. self.root_trace_id = None
  53. self.first_planner_exit_done = False
  54. self.inspection_requested = False
  55. self.call_number = 0
  56. self.planner_histories = []
  57. self.root_worker_prompt = None
  58. def _call(self, name, arguments):
  59. self.call_number += 1
  60. return _tool_call(f"mission-call-{self.call_number}", name, arguments)
  61. async def __call__(self, messages, tools, **_kwargs):
  62. names = {tool["function"]["name"] for tool in tools or []}
  63. if "submit_attempt" in names:
  64. return self._worker(messages)
  65. if "submit_validation" in names:
  66. return self._validator(messages)
  67. if "task_plan" in names:
  68. return await self._planner(messages)
  69. raise AssertionError(f"unexpected role tool set: {sorted(names)}")
  70. def _worker(self, messages):
  71. prompt = _latest_json_user_prompt(messages)
  72. spec = prompt["task_spec"]
  73. if spec["objective"] in (ROOT_OBJECTIVE, ROOT_OBJECTIVE_V2):
  74. self.root_worker_prompt = prompt
  75. return self._call("submit_attempt", {
  76. "summary": f"submitted: {spec['objective']}",
  77. "artifact_refs": [{
  78. "uri": f"memory://result/{spec['version']}",
  79. "version": str(spec["version"]),
  80. }],
  81. "evidence_refs": [],
  82. })
  83. def _validator(self, messages):
  84. prompt = _latest_json_user_prompt(messages)
  85. spec = prompt["task_spec"]
  86. failed_first_child = (
  87. spec["objective"] == CHILD_OBJECTIVE_V1 and spec["version"] == 1
  88. )
  89. verdict = "failed" if failed_first_child else "passed"
  90. return self._call("submit_validation", {
  91. "verdict": verdict,
  92. "criterion_results": [{
  93. "criterion_id": criterion["criterion_id"],
  94. "verdict": verdict,
  95. "reason": "checked against the immutable snapshot",
  96. } for criterion in spec["acceptance_criteria"]],
  97. "summary": f"validation {verdict}",
  98. "evidence_refs": [],
  99. "unverified_claims": [],
  100. "risks": [] if verdict == "passed" else ["task spec needs revision"],
  101. "recommendation": "accept" if verdict == "passed" else "replan",
  102. })
  103. async def _planner(self, messages):
  104. self.planner_histories.append(list(messages))
  105. # Exercise the Runner guard before the Planner performs any operation.
  106. if not self.first_planner_exit_done:
  107. self.first_planner_exit_done = True
  108. return _no_tool()
  109. ledger = await self.task_store.load(self.root_trace_id)
  110. root = ledger.tasks[ledger.root_task_id]
  111. if not self.inspection_requested:
  112. self.inspection_requested = True
  113. return self._call("task_plan", {"operation": "inspect"})
  114. children = [
  115. task for task in ledger.tasks.values()
  116. if task.parent_task_id == root.task_id
  117. ]
  118. if not children:
  119. return self._call("task_plan", {
  120. "operation": "create",
  121. "parent_task_id": root.task_id,
  122. "tasks": [{
  123. "objective": CHILD_OBJECTIVE_V1,
  124. "acceptance_criteria": [{
  125. "criterion_id": "unit-ready",
  126. "description": "the generic unit is ready",
  127. "hard": True,
  128. }],
  129. "context_refs": ["memory://input/generic"],
  130. }],
  131. })
  132. assert len(children) == 1
  133. child = children[0]
  134. if child.status in (TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN):
  135. return self._call("dispatch_tasks", {"task_ids": [child.task_id]})
  136. if child.status == TaskStatus.AWAITING_DECISION:
  137. validation = ledger.validations[child.validation_ids[-1]]
  138. if validation.verdict == ValidationVerdict.FAILED:
  139. return self._call("task_decide", {
  140. "task_id": child.task_id,
  141. "validation_id": validation.validation_id,
  142. "action": "revise",
  143. "reason": "clarify the immutable task specification",
  144. "payload": {
  145. "objective": CHILD_OBJECTIVE_V2,
  146. "acceptance_criteria": [{
  147. "criterion_id": "unit-ready",
  148. "description": "the revised generic unit is ready",
  149. "hard": True,
  150. }],
  151. "context_refs": ["memory://input/generic"],
  152. },
  153. })
  154. assert validation.verdict == ValidationVerdict.PASSED
  155. return self._call("task_decide", {
  156. "task_id": child.task_id,
  157. "validation_id": validation.validation_id,
  158. "action": "accept",
  159. "reason": "the current child version passed validation",
  160. })
  161. assert child.status == TaskStatus.COMPLETED
  162. if root.status in (TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN):
  163. if root.current_spec_version == 1:
  164. return self._call("task_decide", {
  165. "task_id": root.task_id,
  166. "action": "revise",
  167. "reason": "clarify the root objective after child acceptance",
  168. "payload": {
  169. "objective": ROOT_OBJECTIVE_V2,
  170. "acceptance_criteria": [{
  171. "criterion_id": "root-ready",
  172. "description": "the revised root result is ready",
  173. "hard": True,
  174. }],
  175. "context_refs": ["memory://input/root"],
  176. },
  177. })
  178. return self._call("dispatch_tasks", {"task_ids": [root.task_id]})
  179. if root.status == TaskStatus.AWAITING_DECISION:
  180. validation = ledger.validations[root.validation_ids[-1]]
  181. assert validation.verdict == ValidationVerdict.PASSED
  182. return self._call("task_decide", {
  183. "task_id": root.task_id,
  184. "validation_id": validation.validation_id,
  185. "action": "accept",
  186. "reason": "the root result passed independent validation",
  187. })
  188. assert root.status == TaskStatus.COMPLETED
  189. return _no_tool()
  190. def _knowledge_off():
  191. return KnowledgeConfig(
  192. enable_extraction=False,
  193. enable_completion_extraction=False,
  194. enable_injection=False,
  195. )
  196. @pytest.mark.asyncio
  197. async def test_real_runner_completes_replanned_root_mission(tmp_path):
  198. root_trace_id = "mission-loop-root"
  199. trace_store = FileSystemTraceStore(str(tmp_path))
  200. task_store = FileSystemTaskStore(str(tmp_path))
  201. fake_llm = MissionLoopLLM(task_store)
  202. fake_llm.root_trace_id = root_trace_id
  203. runner = AgentRunner(trace_store=trace_store, llm_call=fake_llm)
  204. wire_orchestration(
  205. runner,
  206. task_store,
  207. FileSystemArtifactStore(str(tmp_path)),
  208. )
  209. result = await runner.run_result(
  210. [{"role": "user", "content": "run the generic mission"}],
  211. RunConfig(
  212. agent_type="planner",
  213. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  214. max_iterations=30,
  215. new_trace_id=root_trace_id,
  216. tools=["task_plan", "dispatch_tasks", "task_decide"],
  217. tool_groups=[],
  218. enable_memory=False,
  219. enable_research_flow=False,
  220. knowledge=_knowledge_off(),
  221. root_task_spec={
  222. "objective": ROOT_OBJECTIVE,
  223. "acceptance_criteria": [{
  224. "criterion_id": "root-ready",
  225. "description": "the root result is ready",
  226. "hard": True,
  227. }],
  228. "context_refs": ["memory://input/root"],
  229. },
  230. ),
  231. )
  232. assert result["trace_id"] == root_trace_id
  233. ledger = await task_store.load(result["trace_id"])
  234. root = ledger.tasks[ledger.root_task_id]
  235. children = [task for task in ledger.tasks.values() if task.parent_task_id == root.task_id]
  236. assert result["status"] == "completed", {
  237. "result": result,
  238. "root_status": root.status,
  239. "child_statuses": [task.status for task in children],
  240. "planner_calls": len(fake_llm.planner_histories),
  241. }
  242. assert result["error"] is None
  243. assert len([task for task in ledger.tasks.values() if task.parent_task_id is None]) == 1
  244. assert len(children) == 1
  245. child = children[0]
  246. assert child.current_spec_version == 2
  247. assert child.status == TaskStatus.COMPLETED
  248. assert root.status == TaskStatus.COMPLETED
  249. assert root.current_spec_version == 2
  250. assert root.current_spec.objective == ROOT_OBJECTIVE_V2
  251. assert len(child.attempt_ids) == 2
  252. assert len(root.attempt_ids) == 1
  253. assert fake_llm.root_worker_prompt is not None
  254. accepted = fake_llm.root_worker_prompt["accepted_child_results"]
  255. assert [item["task_id"] for item in accepted] == [child.task_id]
  256. assert accepted[0]["task_spec"]["version"] == 2
  257. assert accepted[0]["submission"]["summary"] == (
  258. f"submitted: {CHILD_OBJECTIVE_V2}"
  259. )
  260. planner_history = json.dumps(fake_llm.planner_histories, ensure_ascii=False, default=str)
  261. assert len(ledger.validations) == 3
  262. assert all(
  263. validation.validation_id in planner_history
  264. for validation in ledger.validations.values()
  265. )
  266. planner_messages = await trace_store.get_trace_messages(result["trace_id"])
  267. assert any(
  268. "[FRAMEWORK_MISSION_INCOMPLETE]" in str(message.content)
  269. for message in planner_messages
  270. )
  271. traces = await trace_store.list_traces(limit=50)
  272. planner_traces = [trace for trace in traces if trace.agent_role == AgentRole.PLANNER.value]
  273. worker_traces = [trace for trace in traces if trace.agent_role == AgentRole.WORKER.value]
  274. validator_traces = [trace for trace in traces if trace.agent_role == AgentRole.VALIDATOR.value]
  275. assert [trace.trace_id for trace in planner_traces] == [result["trace_id"]]
  276. assert len(worker_traces) == 3
  277. assert len(validator_traces) == 3
  278. assert len({trace.trace_id for trace in worker_traces + validator_traces}) == 6
  279. assert all(trace.parent_trace_id == result["trace_id"] for trace in worker_traces + validator_traces)
  280. assert all(trace.status == "completed" for trace in worker_traces + validator_traces)