test_no_progress_policy.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import pytest
  2. from agent.core.presets import AgentPreset, register_preset
  3. from agent.core.runner import AgentRunner, NoProgressPolicy, RunConfig
  4. from agent.failures import FailureDetail, FailureDisposition, ToolExecutionError
  5. from agent.orchestration.models import AgentRole, CompletionPolicy
  6. from agent.trace.store import FileSystemTraceStore
  7. from agent.tools.models import ToolCapability
  8. from agent.tools.registry import ToolRegistry
  9. from test_coordinator_integration import FakeExecutor, create_task, make_coordinator
  10. class StaticProgressCoordinator:
  11. def __init__(self):
  12. self.ensure_calls = 0
  13. async def ensure_ledger(self, *_args, **_kwargs):
  14. self.ensure_calls += 1
  15. async def root_completion(self, _root_trace_id):
  16. return {
  17. "status": "needs_replan",
  18. "root_task_id": "ignored-id",
  19. "pending_tasks": [{"objective": "same task", "status": "needs_replan"}],
  20. }
  21. async def progress_snapshot(self, _root_trace_id):
  22. return {
  23. "root": {"objective": "same task", "status": "needs_replan"},
  24. "active_tasks": [],
  25. "accepted": [],
  26. }
  27. def _knowledge_off():
  28. from agent.tools.builtin.knowledge import KnowledgeConfig
  29. return KnowledgeConfig(
  30. enable_extraction=False,
  31. enable_completion_extraction=False,
  32. enable_injection=False,
  33. )
  34. def _explicit_config(preset, *, max_iterations=8, tools=None):
  35. return RunConfig(
  36. agent_type=preset,
  37. completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
  38. max_iterations=max_iterations,
  39. tools=tools or [],
  40. tool_groups=[],
  41. root_task_spec={
  42. "objective": "same task",
  43. "acceptance_criteria": [{"description": "finish it"}],
  44. },
  45. knowledge=_knowledge_off(),
  46. )
  47. def _epoch_config(preset, epoch, *, max_iterations):
  48. config = _explicit_config(preset, max_iterations=max_iterations)
  49. config.no_progress = NoProgressPolicy(progress_epoch=epoch)
  50. config._role_run_config_override_fields = frozenset({"max_iterations"})
  51. return config
  52. @pytest.mark.asyncio
  53. async def test_planner_stops_after_three_unchanged_semantic_cycles(tmp_path):
  54. preset = "test_no_progress_planner_stall"
  55. register_preset(
  56. preset,
  57. AgentPreset(
  58. role=AgentRole.PLANNER, allowed_tools=[], max_iterations=8, skills=[]
  59. ),
  60. )
  61. calls = 0
  62. async def llm_call(**_kwargs):
  63. nonlocal calls
  64. calls += 1
  65. return {
  66. "content": "still thinking",
  67. "tool_calls": None,
  68. "finish_reason": "stop",
  69. }
  70. store = FileSystemTraceStore(str(tmp_path))
  71. result = await AgentRunner(
  72. trace_store=store,
  73. llm_call=llm_call,
  74. task_coordinator=StaticProgressCoordinator(),
  75. ).run_result(
  76. [{"role": "user", "content": "plan"}],
  77. _explicit_config(preset),
  78. )
  79. assert calls == 3
  80. assert result["status"] == "incomplete"
  81. assert result["failure"]["code"] == "NO_PROGRESS"
  82. events = await store.get_events(result["trace_id"])
  83. assert [event["event"] for event in events].count("no_progress_detected") == 1
  84. @pytest.mark.asyncio
  85. async def test_second_identical_tool_failure_stops_worker(tmp_path):
  86. preset = "test_no_progress_worker_failure"
  87. register_preset(
  88. preset,
  89. AgentPreset(
  90. role=AgentRole.WORKER,
  91. allowed_tools=["flaky_write"],
  92. max_iterations=8,
  93. skills=[],
  94. ),
  95. )
  96. registry = ToolRegistry()
  97. async def flaky_write():
  98. raise ToolExecutionError(
  99. FailureDetail(
  100. code="WORKSPACE_NOT_READY",
  101. message="create the missing workspace file",
  102. disposition=FailureDisposition.REPAIR_ATTEMPT,
  103. source_tool="flaky_write",
  104. )
  105. )
  106. registry.register(flaky_write, capabilities=[ToolCapability.WRITE])
  107. calls = 0
  108. async def llm_call(**_kwargs):
  109. nonlocal calls
  110. calls += 1
  111. return {
  112. "content": "",
  113. "tool_calls": [
  114. {
  115. "id": f"call-{calls}",
  116. "type": "function",
  117. "function": {"name": "flaky_write", "arguments": "{}"},
  118. }
  119. ],
  120. "finish_reason": "tool_calls",
  121. }
  122. result = await AgentRunner(
  123. trace_store=FileSystemTraceStore(str(tmp_path)),
  124. tool_registry=registry,
  125. llm_call=llm_call,
  126. task_coordinator=StaticProgressCoordinator(),
  127. ).run_result(
  128. [{"role": "user", "content": "work"}],
  129. _explicit_config(preset, tools=["flaky_write"]),
  130. )
  131. assert calls == 2
  132. assert result["status"] == "failed"
  133. assert result["failure"]["code"] == "NO_PROGRESS"
  134. assert result["failure"]["details"]["last_failure"]["code"] == "WORKSPACE_NOT_READY"
  135. @pytest.mark.asyncio
  136. async def test_distinct_planner_failures_count_as_semantic_feedback_progress(tmp_path):
  137. preset = "test_no_progress_distinct_planner_failures"
  138. register_preset(
  139. preset,
  140. AgentPreset(
  141. role=AgentRole.PLANNER,
  142. allowed_tools=["reject_plan"],
  143. max_iterations=4,
  144. skills=[],
  145. ),
  146. )
  147. registry = ToolRegistry()
  148. async def reject_plan(attempted_kind: str):
  149. raise ToolExecutionError(
  150. FailureDetail(
  151. code="PHASE_POLICY_VIOLATION",
  152. message=f"cannot plan {attempted_kind}",
  153. disposition=FailureDisposition.REPLAN_TASK,
  154. source_tool="reject_plan",
  155. details={"attempted_task_kinds": [attempted_kind]},
  156. )
  157. )
  158. registry.register(reject_plan, capabilities=[ToolCapability.TASK_CONTROL])
  159. calls = 0
  160. async def llm_call(**_kwargs):
  161. nonlocal calls
  162. calls += 1
  163. attempted = ["structure", "paragraph", "element-set", "compare"][calls - 1]
  164. return {
  165. "content": "",
  166. "tool_calls": [
  167. {
  168. "id": f"call-{calls}",
  169. "type": "function",
  170. "function": {
  171. "name": "reject_plan",
  172. "arguments": f'{{"attempted_kind":"{attempted}"}}',
  173. },
  174. }
  175. ],
  176. "finish_reason": "tool_calls",
  177. }
  178. await AgentRunner(
  179. trace_store=FileSystemTraceStore(str(tmp_path)),
  180. tool_registry=registry,
  181. llm_call=llm_call,
  182. task_coordinator=StaticProgressCoordinator(),
  183. ).run_result(
  184. [{"role": "user", "content": "plan"}],
  185. _explicit_config(preset, max_iterations=4, tools=["reject_plan"]),
  186. )
  187. assert calls == 4
  188. @pytest.mark.asyncio
  189. async def test_planner_stall_counter_survives_resume(tmp_path):
  190. preset = "test_no_progress_resume"
  191. register_preset(
  192. preset,
  193. AgentPreset(
  194. role=AgentRole.PLANNER, allowed_tools=[], max_iterations=4, skills=[]
  195. ),
  196. )
  197. calls = 0
  198. async def llm_call(**_kwargs):
  199. nonlocal calls
  200. calls += 1
  201. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  202. store = FileSystemTraceStore(str(tmp_path))
  203. runner = AgentRunner(
  204. trace_store=store,
  205. llm_call=llm_call,
  206. task_coordinator=StaticProgressCoordinator(),
  207. )
  208. first_config = _explicit_config(preset, max_iterations=2)
  209. first_config._role_run_config_override_fields = frozenset({"max_iterations"})
  210. first = await runner.run_result([{"role": "user", "content": "plan"}], first_config)
  211. assert first["status"] == "incomplete"
  212. assert calls == 2
  213. resumed = await runner.run_result(
  214. [],
  215. RunConfig(trace_id=first["trace_id"], knowledge=_knowledge_off()),
  216. )
  217. assert calls == 3
  218. assert resumed["status"] == "incomplete"
  219. assert resumed["failure"]["code"] == "NO_PROGRESS"
  220. @pytest.mark.asyncio
  221. async def test_planner_stall_counter_resets_for_new_progress_epoch(tmp_path):
  222. preset = "test_no_progress_new_epoch"
  223. register_preset(
  224. preset,
  225. AgentPreset(
  226. role=AgentRole.PLANNER, allowed_tools=[], max_iterations=4, skills=[]
  227. ),
  228. )
  229. calls = 0
  230. async def llm_call(**_kwargs):
  231. nonlocal calls
  232. calls += 1
  233. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  234. store = FileSystemTraceStore(str(tmp_path))
  235. runner = AgentRunner(
  236. trace_store=store,
  237. llm_call=llm_call,
  238. task_coordinator=StaticProgressCoordinator(),
  239. )
  240. first = await runner.run_result(
  241. [{"role": "user", "content": "phase one"}],
  242. _epoch_config(preset, "phase-one", max_iterations=2),
  243. )
  244. assert first["status"] == "incomplete"
  245. assert calls == 2
  246. resumed = await runner.run_result(
  247. [{"role": "user", "content": "phase two"}],
  248. RunConfig(
  249. trace_id=first["trace_id"],
  250. no_progress=NoProgressPolicy(progress_epoch="phase-two"),
  251. knowledge=_knowledge_off(),
  252. ),
  253. )
  254. assert calls == 5
  255. assert resumed["status"] == "incomplete"
  256. assert resumed["failure"]["code"] == "NO_PROGRESS"
  257. @pytest.mark.asyncio
  258. async def test_semantically_equal_task_ledgers_ignore_generated_ids(tmp_path):
  259. first, _, _ = await make_coordinator(tmp_path / "first", FakeExecutor([]))
  260. second, _, _ = await make_coordinator(tmp_path / "second", FakeExecutor([]))
  261. first_task_id = await create_task(first, objective="equivalent child")
  262. second_task_id = await create_task(second, objective="equivalent child")
  263. assert first_task_id != second_task_id
  264. assert await first.progress_snapshot("root") == await second.progress_snapshot(
  265. "root"
  266. )
  267. def test_no_progress_policy_rejects_invalid_thresholds():
  268. with pytest.raises(ValueError, match="same_failure_limit"):
  269. NoProgressPolicy(same_failure_limit=0)
  270. with pytest.raises(ValueError, match="planner_stall_limit"):
  271. NoProgressPolicy(planner_stall_limit=0)