Python · 5625 bytes Raw Blame History
1 """Tests for main turn-loop orchestration."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6
7 import pytest
8
9 from loader.agent.loop import Agent, AgentConfig
10 from loader.runtime.conversation import ConversationRuntime
11 from loader.runtime.turn_iteration import TurnIterationAction, TurnIterationDecision
12 from loader.runtime.turn_preamble import TurnPreludeDecision
13 from tests.helpers.runtime_harness import ScriptedBackend
14
15
16 def non_streaming_config() -> AgentConfig:
17 """Shared config for direct turn-loop tests."""
18
19 return AgentConfig(auto_context=False, stream=False, max_iterations=8)
20
21
22 async def _prepare_runtime(
23 runtime: ConversationRuntime,
24 *,
25 task: str,
26 ) -> tuple:
27 events = []
28
29 async def capture(event) -> None:
30 events.append(event)
31
32 prepared = await runtime.turn_preparation.prepare(
33 task=task,
34 emit=capture,
35 requested_mode="execute",
36 original_task=None,
37 on_user_question=None,
38 )
39 return prepared, events, capture
40
41
42 @pytest.mark.asyncio
43 async def test_turn_loop_retries_after_preamble_continue(
44 temp_dir: Path,
45 ) -> None:
46 backend = ScriptedBackend()
47 agent = Agent(
48 backend=backend,
49 config=non_streaming_config(),
50 project_root=temp_dir,
51 )
52 runtime = ConversationRuntime(agent)
53 prepared, _, capture = await _prepare_runtime(
54 runtime,
55 task="Explain the runtime loop shape.",
56 )
57 prelude_calls: list[int] = []
58 iteration_calls: list[int] = []
59
60 async def fake_preamble(**kwargs):
61 prelude_calls.append(kwargs["iterations"])
62 kwargs["summary"].iterations = kwargs["iterations"]
63 return TurnPreludeDecision(should_continue=kwargs["iterations"] == 1)
64
65 async def fake_iteration(**kwargs):
66 iteration_calls.append(kwargs["iterations"])
67 return TurnIterationDecision(
68 action=TurnIterationAction.COMPLETE,
69 continuation_count=0,
70 empty_retry_count=0,
71 extracted_iterations=0,
72 consecutive_errors=0,
73 )
74
75 runtime.turn_preamble.prepare_iteration = fake_preamble
76 runtime.turn_iteration.run_iteration = fake_iteration
77
78 loop_exit = await runtime.turn_loop.run_loop(
79 task=prepared.task,
80 effective_task=prepared.effective_task,
81 original_task=None,
82 effective_max_tokens=prepared.effective_max_tokens,
83 dod=prepared.definition_of_done,
84 emit=capture,
85 summary=prepared.summary,
86 executor=prepared.executor,
87 rollback_plan=prepared.rollback_plan,
88 on_confirmation=None,
89 on_user_question=None,
90 emit_confirmation=runtime._emit_confirmation(capture),
91 )
92
93 assert prelude_calls == [1, 2]
94 assert iteration_calls == [2]
95 assert prepared.summary.iterations == 2
96 assert loop_exit.reason_code == "turn_complete"
97 assert loop_exit.reason_summary == "Finalizing completed turn"
98
99
100 @pytest.mark.asyncio
101 async def test_turn_loop_carries_iteration_state_between_attempts(
102 temp_dir: Path,
103 ) -> None:
104 backend = ScriptedBackend()
105 agent = Agent(
106 backend=backend,
107 config=non_streaming_config(),
108 project_root=temp_dir,
109 )
110 runtime = ConversationRuntime(agent)
111 prepared, _, capture = await _prepare_runtime(
112 runtime,
113 task="Inspect the loop bookkeeping.",
114 )
115 iteration_inputs: list[tuple[int, int, int, int, int, list[str]]] = []
116
117 async def fake_preamble(**kwargs):
118 kwargs["summary"].iterations = kwargs["iterations"]
119 return TurnPreludeDecision()
120
121 async def fake_iteration(**kwargs):
122 iteration_inputs.append(
123 (
124 kwargs["iterations"],
125 kwargs["continuation_count"],
126 kwargs["empty_retry_count"],
127 kwargs["extracted_iterations"],
128 kwargs["consecutive_errors"],
129 list(kwargs["actions_taken"]),
130 )
131 )
132 if len(iteration_inputs) == 1:
133 return TurnIterationDecision(
134 action=TurnIterationAction.CONTINUE,
135 continuation_count=1,
136 empty_retry_count=2,
137 extracted_iterations=1,
138 consecutive_errors=1,
139 new_actions_taken=["read: {'file_path': 'README.md'}"],
140 )
141 return TurnIterationDecision(
142 action=TurnIterationAction.FINALIZE,
143 continuation_count=1,
144 empty_retry_count=2,
145 extracted_iterations=1,
146 consecutive_errors=0,
147 finalize_reason_code="tool_batch_halted",
148 finalize_reason_summary="Finalizing after halted tool batch",
149 )
150
151 runtime.turn_preamble.prepare_iteration = fake_preamble
152 runtime.turn_iteration.run_iteration = fake_iteration
153
154 loop_exit = await runtime.turn_loop.run_loop(
155 task=prepared.task,
156 effective_task=prepared.effective_task,
157 original_task=None,
158 effective_max_tokens=prepared.effective_max_tokens,
159 dod=prepared.definition_of_done,
160 emit=capture,
161 summary=prepared.summary,
162 executor=prepared.executor,
163 rollback_plan=prepared.rollback_plan,
164 on_confirmation=None,
165 on_user_question=None,
166 emit_confirmation=runtime._emit_confirmation(capture),
167 )
168
169 assert iteration_inputs == [
170 (1, 0, 0, 0, 0, []),
171 (2, 1, 2, 1, 1, ["read: {'file_path': 'README.md'}"]),
172 ]
173 assert loop_exit.reason_code == "tool_batch_halted"
174 assert loop_exit.reason_summary == "Finalizing after halted tool batch"