| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- import pytest
- from agent.core.presets import AgentPreset, register_preset
- from agent.core.runner import AgentRunner, RunConfig
- from agent.orchestration.models import AgentRole, CompletionPolicy
- from agent.trace.models import Trace
- from agent.trace.store import FileSystemTraceStore
- class StubCoordinator:
- def __init__(self, snapshots):
- self.snapshots = list(snapshots)
- self.ensure_calls = []
- self.completion_calls = 0
- async def ensure_ledger(self, trace_id, mission, *, allow_create=True):
- self.ensure_calls.append((trace_id, mission))
- async def mission_completion(self, root_trace_id):
- self.completion_calls += 1
- if len(self.snapshots) > 1:
- return self.snapshots.pop(0)
- return self.snapshots[0]
- class RootFirstCoordinator(StubCoordinator):
- def __init__(self):
- super().__init__([{"status": "legacy"}])
- self.root_calls = 0
- async def root_completion(self, root_trace_id):
- self.root_calls += 1
- return {"status": "completed", "root_task_id": "root-task"}
- def _knowledge_off():
- from agent.tools.builtin.knowledge import KnowledgeConfig
- return KnowledgeConfig(
- enable_extraction=False,
- enable_completion_extraction=False,
- enable_injection=False,
- )
- @pytest.mark.asyncio
- async def test_runner_prefers_root_completion_and_keeps_legacy_fallback(tmp_path):
- root_first = RootFirstCoordinator()
- runner = AgentRunner(
- trace_store=FileSystemTraceStore(str(tmp_path / "root-first")),
- llm_call=None,
- task_coordinator=root_first,
- )
- assert (await runner._get_mission_completion("root"))["status"] == "completed"
- assert root_first.root_calls == 1
- assert root_first.completion_calls == 0
- legacy = StubCoordinator([{"status": "blocked"}])
- runner.task_coordinator = legacy
- assert (await runner._get_mission_completion("root"))["status"] == "blocked"
- assert legacy.completion_calls == 1
- def _register_role_preset(name, role, max_iterations):
- register_preset(
- name,
- AgentPreset(
- role=role,
- allowed_tools=[],
- max_iterations=max_iterations,
- skills=[],
- ),
- )
- def _explicit_config(name, max_iterations):
- root_task_spec = {
- "objective": "complete the mission",
- "acceptance_criteria": [{"description": "mission result is validated"}],
- }
- return RunConfig(
- agent_type=name,
- completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
- max_iterations=max_iterations,
- tools=[],
- tool_groups=[],
- root_task_spec=root_task_spec,
- knowledge=_knowledge_off(),
- )
- @pytest.mark.asyncio
- async def test_planner_no_tool_exit_is_persisted_and_loop_continues(tmp_path):
- preset = "test_completion_gate_planner"
- _register_role_preset(preset, AgentRole.PLANNER, 3)
- coordinator = StubCoordinator([
- {
- "status": "needs_replan",
- "root_task_id": "root-task",
- "pending_tasks": [{"task_id": "child", "status": "pending"}],
- },
- {
- "status": "completed",
- "root_task_id": "root-task",
- "pending_tasks": [],
- "result_summary": "accepted root result",
- },
- ])
- calls = []
- async def llm_call(**kwargs):
- calls.append(kwargs["messages"])
- return {"content": "", "tool_calls": None, "finish_reason": "stop"}
- store = FileSystemTraceStore(str(tmp_path))
- runner = AgentRunner(
- trace_store=store,
- llm_call=llm_call,
- task_coordinator=coordinator,
- )
- events = [
- event
- async for event in runner.run(
- [{"role": "user", "content": "run mission"}],
- _explicit_config(preset, 3),
- )
- ]
- final = events[-1]
- assert isinstance(final, Trace)
- assert final.status == "completed"
- assert final.result_summary == "accepted root result"
- assert coordinator.ensure_calls[0][1]["objective"] == "complete the mission"
- assert len(calls) == 2
- assert any(
- message.get("role") == "user"
- and "[FRAMEWORK_MISSION_INCOMPLETE]" in message.get("content", "")
- for message in calls[1]
- )
- assert calls[1][0]["role"] == "system"
- assert "Framework role contract: Planner" in calls[1][0]["content"]
- persisted = await store.get_trace_messages(final.trace_id)
- controls = [
- message for message in persisted
- if message.role == "user"
- and "[FRAMEWORK_MISSION_INCOMPLETE]" in str(message.content)
- ]
- assert len(controls) == 1
- @pytest.mark.asyncio
- async def test_incomplete_planner_resume_restores_explicit_identity(tmp_path):
- preset = "test_completion_gate_resume"
- _register_role_preset(preset, AgentRole.PLANNER, 1)
- coordinator = StubCoordinator([{
- "status": "needs_replan",
- "root_task_id": "root-task",
- "pending_tasks": [{"task_id": "root-task", "status": "needs_replan"}],
- }])
- async def llm_call(**_kwargs):
- return {"content": "", "tool_calls": None, "finish_reason": "stop"}
- store = FileSystemTraceStore(str(tmp_path))
- runner = AgentRunner(
- trace_store=store,
- llm_call=llm_call,
- task_coordinator=coordinator,
- )
- first = await runner.run_result(
- [{"role": "user", "content": "run mission"}],
- _explicit_config(preset, 1),
- )
- assert first["status"] == "incomplete"
- resume_config = RunConfig(
- trace_id=first["trace_id"],
- knowledge=_knowledge_off(),
- )
- resumed = await runner.run_result([], resume_config)
- assert resumed["status"] == "incomplete"
- assert resumed["error"] == "iteration_limit_reached"
- assert resume_config.agent_type == preset
- assert resume_config.completion_policy == CompletionPolicy.EXPLICIT_VALIDATION
- @pytest.mark.asyncio
- async def test_explicit_planner_rewind_and_wrong_role_resume_are_rejected_before_mutation(
- tmp_path,
- ):
- planner_preset = "test_completion_gate_rewind_planner"
- worker_preset = "test_completion_gate_wrong_role_worker"
- _register_role_preset(planner_preset, AgentRole.PLANNER, 2)
- _register_role_preset(worker_preset, AgentRole.WORKER, 2)
- store = FileSystemTraceStore(str(tmp_path))
- planner_trace = Trace(
- trace_id="planner-trace",
- mode="agent",
- task="mission",
- agent_type=planner_preset,
- agent_role=AgentRole.PLANNER.value,
- status="completed",
- head_sequence=0,
- last_sequence=10,
- )
- worker_trace = Trace(
- trace_id="worker-trace",
- mode="agent",
- task="worker",
- agent_type=worker_preset,
- agent_role=AgentRole.WORKER.value,
- status="completed",
- )
- await store.create_trace(planner_trace)
- await store.create_trace(worker_trace)
- coordinator = StubCoordinator([{"status": "completed"}])
- runner = AgentRunner(
- trace_store=store,
- llm_call=lambda **_kwargs: None,
- task_coordinator=coordinator,
- )
- with pytest.raises(ValueError, match="do not support rewind"):
- await runner.run_result(
- [],
- RunConfig(
- trace_id=planner_trace.trace_id,
- agent_type=planner_preset,
- completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
- after_sequence=5,
- ),
- )
- with pytest.raises(ValueError, match="Trace role mismatch"):
- await runner.run_result(
- [],
- RunConfig(
- trace_id=worker_trace.trace_id,
- agent_type=planner_preset,
- completion_policy=CompletionPolicy.EXPLICIT_VALIDATION,
- ),
- )
- assert (await store.get_trace(planner_trace.trace_id)).status == "completed"
- assert (await store.get_trace(worker_trace.trace_id)).status == "completed"
- assert coordinator.ensure_calls == []
- @pytest.mark.asyncio
- async def test_planner_iteration_exhaustion_is_incomplete(tmp_path):
- preset = "test_completion_gate_exhaustion"
- _register_role_preset(preset, AgentRole.PLANNER, 2)
- coordinator = StubCoordinator([{
- "status": "waiting_children",
- "root_task_id": "root-task",
- "pending_tasks": ["child"],
- }])
- async def llm_call(**_kwargs):
- return {"content": "", "tool_calls": None, "finish_reason": "stop"}
- runner = AgentRunner(
- trace_store=FileSystemTraceStore(str(tmp_path)),
- llm_call=llm_call,
- task_coordinator=coordinator,
- )
- result = await runner.run_result(
- [{"role": "user", "content": "run mission"}],
- _explicit_config(preset, 2),
- )
- assert result["status"] == "incomplete"
- assert result["error"] == "iteration_limit_reached"
- assert result["summary"] is None
- @pytest.mark.asyncio
- async def test_blocked_root_stops_planner_as_incomplete(tmp_path):
- preset = "test_completion_gate_blocked"
- _register_role_preset(preset, AgentRole.PLANNER, 3)
- coordinator = StubCoordinator([{
- "status": "blocked",
- "root_task_id": "root-task",
- "blocked_reason": "external dependency",
- "pending_tasks": [],
- }])
- calls = 0
- async def llm_call(**_kwargs):
- nonlocal calls
- calls += 1
- return {"content": "blocked", "tool_calls": None, "finish_reason": "stop"}
- runner = AgentRunner(
- trace_store=FileSystemTraceStore(str(tmp_path)),
- llm_call=llm_call,
- task_coordinator=coordinator,
- )
- result = await runner.run_result(
- [{"role": "user", "content": "run mission"}],
- _explicit_config(preset, 3),
- )
- assert calls == 1
- assert result["status"] == "incomplete"
- assert result["error"] == "mission_blocked"
- @pytest.mark.asyncio
- @pytest.mark.parametrize("role", [AgentRole.WORKER, AgentRole.VALIDATOR])
- async def test_explicit_subtrace_without_terminal_submit_is_protocol_failure(
- tmp_path, role
- ):
- preset = "test_completion_gate_" + role.value
- _register_role_preset(preset, role, 2)
- async def llm_call(**_kwargs):
- return {"content": "done", "tool_calls": None, "finish_reason": "stop"}
- runner = AgentRunner(
- trace_store=FileSystemTraceStore(str(tmp_path / role.value)),
- llm_call=llm_call,
- task_coordinator=StubCoordinator([{"status": "completed"}]),
- )
- result = await runner.run_result(
- [{"role": "user", "content": "sub task"}],
- _explicit_config(preset, 2),
- )
- assert result["status"] == "failed"
- assert result["error"] == "protocol_violation"
|