test_no_progress_policy.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. @pytest.mark.asyncio
  48. async def test_planner_stops_after_three_unchanged_semantic_cycles(tmp_path):
  49. preset = "test_no_progress_planner_stall"
  50. register_preset(
  51. preset,
  52. AgentPreset(role=AgentRole.PLANNER, allowed_tools=[], max_iterations=8, skills=[]),
  53. )
  54. calls = 0
  55. async def llm_call(**_kwargs):
  56. nonlocal calls
  57. calls += 1
  58. return {"content": "still thinking", "tool_calls": None, "finish_reason": "stop"}
  59. store = FileSystemTraceStore(str(tmp_path))
  60. result = await AgentRunner(
  61. trace_store=store,
  62. llm_call=llm_call,
  63. task_coordinator=StaticProgressCoordinator(),
  64. ).run_result(
  65. [{"role": "user", "content": "plan"}],
  66. _explicit_config(preset),
  67. )
  68. assert calls == 3
  69. assert result["status"] == "incomplete"
  70. assert result["failure"]["code"] == "NO_PROGRESS"
  71. events = await store.get_events(result["trace_id"])
  72. assert [event["event"] for event in events].count("no_progress_detected") == 1
  73. @pytest.mark.asyncio
  74. async def test_second_identical_tool_failure_stops_worker(tmp_path):
  75. preset = "test_no_progress_worker_failure"
  76. register_preset(
  77. preset,
  78. AgentPreset(
  79. role=AgentRole.WORKER,
  80. allowed_tools=["flaky_write"],
  81. max_iterations=8,
  82. skills=[],
  83. ),
  84. )
  85. registry = ToolRegistry()
  86. async def flaky_write():
  87. raise ToolExecutionError(
  88. FailureDetail(
  89. code="WORKSPACE_NOT_READY",
  90. message="create the missing workspace file",
  91. disposition=FailureDisposition.REPAIR_ATTEMPT,
  92. source_tool="flaky_write",
  93. )
  94. )
  95. registry.register(flaky_write, capabilities=[ToolCapability.WRITE])
  96. calls = 0
  97. async def llm_call(**_kwargs):
  98. nonlocal calls
  99. calls += 1
  100. return {
  101. "content": "",
  102. "tool_calls": [{
  103. "id": f"call-{calls}",
  104. "type": "function",
  105. "function": {"name": "flaky_write", "arguments": "{}"},
  106. }],
  107. "finish_reason": "tool_calls",
  108. }
  109. result = await AgentRunner(
  110. trace_store=FileSystemTraceStore(str(tmp_path)),
  111. tool_registry=registry,
  112. llm_call=llm_call,
  113. task_coordinator=StaticProgressCoordinator(),
  114. ).run_result(
  115. [{"role": "user", "content": "work"}],
  116. _explicit_config(preset, tools=["flaky_write"]),
  117. )
  118. assert calls == 2
  119. assert result["status"] == "failed"
  120. assert result["failure"]["code"] == "NO_PROGRESS"
  121. assert result["failure"]["details"]["last_failure"]["code"] == "WORKSPACE_NOT_READY"
  122. @pytest.mark.asyncio
  123. async def test_planner_stall_counter_survives_resume(tmp_path):
  124. preset = "test_no_progress_resume"
  125. register_preset(
  126. preset,
  127. AgentPreset(role=AgentRole.PLANNER, allowed_tools=[], max_iterations=4, skills=[]),
  128. )
  129. calls = 0
  130. async def llm_call(**_kwargs):
  131. nonlocal calls
  132. calls += 1
  133. return {"content": "", "tool_calls": None, "finish_reason": "stop"}
  134. store = FileSystemTraceStore(str(tmp_path))
  135. runner = AgentRunner(
  136. trace_store=store,
  137. llm_call=llm_call,
  138. task_coordinator=StaticProgressCoordinator(),
  139. )
  140. first_config = _explicit_config(preset, max_iterations=2)
  141. first_config._role_run_config_override_fields = frozenset({"max_iterations"})
  142. first = await runner.run_result([{"role": "user", "content": "plan"}], first_config)
  143. assert first["status"] == "incomplete"
  144. assert calls == 2
  145. resumed = await runner.run_result(
  146. [],
  147. RunConfig(trace_id=first["trace_id"], knowledge=_knowledge_off()),
  148. )
  149. assert calls == 3
  150. assert resumed["status"] == "incomplete"
  151. assert resumed["failure"]["code"] == "NO_PROGRESS"
  152. @pytest.mark.asyncio
  153. async def test_semantically_equal_task_ledgers_ignore_generated_ids(tmp_path):
  154. first, _, _ = await make_coordinator(tmp_path / "first", FakeExecutor([]))
  155. second, _, _ = await make_coordinator(tmp_path / "second", FakeExecutor([]))
  156. first_task_id = await create_task(first, objective="equivalent child")
  157. second_task_id = await create_task(second, objective="equivalent child")
  158. assert first_task_id != second_task_id
  159. assert await first.progress_snapshot("root") == await second.progress_snapshot("root")
  160. def test_no_progress_policy_rejects_invalid_thresholds():
  161. with pytest.raises(ValueError, match="same_failure_limit"):
  162. NoProgressPolicy(same_failure_limit=0)
  163. with pytest.raises(ValueError, match="planner_stall_limit"):
  164. NoProgressPolicy(planner_stall_limit=0)