test_mission_loop.py 11 KB

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