diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 1e2132c1d..1991c7004 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -16,12 +16,14 @@ from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.tools.registry import ToolRegistry from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.utils.file_edit_events import ( + StreamingFileEditTracker, build_file_edit_end_event, build_file_edit_error_event, build_file_edit_start_event, - prepare_file_edit_tracker as _prepare_file_edit_tracker, prepare_file_edit_trackers, - StreamingFileEditTracker, +) +from nanobot.utils.file_edit_events import ( + prepare_file_edit_tracker as _prepare_file_edit_tracker, ) from nanobot.utils.helpers import ( IncrementalThinkExtractor, @@ -179,16 +181,19 @@ class AgentRunner: and *iteration* are both provided) and return (True, cycles+1) so the caller continues the iteration loop. Otherwise return (False, cycles). """ - if injection_cycles >= _MAX_INJECTION_CYCLES: - return False, injection_cycles - injections = await self._drain_injections(spec) + injections: list[dict[str, Any]] = [] + real_injection = False + if injection_cycles < _MAX_INJECTION_CYCLES: + injections = await self._drain_injections(spec) + real_injection = bool(injections) if not injections and allow_goal_continue and assistant_message is not None: predicate = spec.goal_active_predicate if predicate is not None and predicate(): injections = [build_goal_continue_message(spec.goal_continue_message)] if not injections: return False, injection_cycles - injection_cycles += 1 + if real_injection: + injection_cycles += 1 if assistant_message is not None: messages.append(assistant_message) if iteration is not None: @@ -204,10 +209,13 @@ class AgentRunner: }, ) self._append_injected_messages(messages, injections) - logger.info( - "Injected {} follow-up message(s) {} ({}/{})", - len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES, - ) + if real_injection: + logger.info( + "Injected {} follow-up message(s) {} ({}/{})", + len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES, + ) + else: + logger.info("Injected sustained-goal continuation {}", phase) return True, injection_cycles async def _drain_injections(self, spec: AgentRunSpec) -> list[dict[str, Any]]: diff --git a/tests/agent/test_runner_goal_continue.py b/tests/agent/test_runner_goal_continue.py index ae41e849a..88be011ec 100644 --- a/tests/agent/test_runner_goal_continue.py +++ b/tests/agent/test_runner_goal_continue.py @@ -129,6 +129,33 @@ async def test_runner_respects_max_iterations_even_with_active_goal(): assert result.stop_reason == "max_iterations" +@pytest.mark.asyncio +async def test_runner_goal_continue_not_limited_by_injection_cycle_cap(): + """Synthetic goal continuation should be governed by max_iterations.""" + from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec + + provider = MagicMock(spec=LLMProvider) + provider.chat_with_retry = AsyncMock(return_value=LLMResponse( + content="still working", tool_calls=[], usage={}, + )) + tools = MagicMock() + tools.get_definitions.return_value = [] + max_iterations = _MAX_INJECTION_CYCLES + 3 + + runner = AgentRunner(provider) + result = await runner.run(AgentRunSpec( + initial_messages=[{"role": "user", "content": "do task"}], + tools=tools, + model="test-model", + max_iterations=max_iterations, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + goal_active_predicate=lambda: True, + )) + + assert result.stop_reason == "max_iterations" + assert provider.chat_with_retry.await_count == max_iterations + + @pytest.mark.asyncio async def test_runner_does_not_force_continue_on_error(): """Even with active goal, an LLM error should exit with stop_reason="error"."""