diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 250db317..734b0789 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -301,12 +301,13 @@ class AgentLoop: # One file-read/write tracker per logical session. The tool registry is # shared by this loop, so tools resolve the active state via contextvars. self._file_state_store = FileStateStore() - self.runner = AgentRunner(provider) + self.runner = AgentRunner() self.subagents = SubagentManager( provider=provider, workspace=workspace, bus=bus, model=self.model, + context_window_tokens=self.context_window_tokens, tools_config=_tc, max_tool_result_chars=self.max_tool_result_chars, restrict_to_workspace=restrict_to_workspace, @@ -456,8 +457,7 @@ class AgentLoop: self.provider = provider self.model = model self.context_window_tokens = context_window_tokens - self.runner.provider = provider - self.subagents.set_provider(provider, model) + self.subagents.set_provider(provider, model, context_window_tokens) self.consolidator.set_provider(provider, model, context_window_tokens) self._sync_replay_max_messages() self._provider_signature = snapshot.signature @@ -864,7 +864,7 @@ class AgentLoop: result = await self.runner.run(AgentRunSpec( initial_messages=initial_messages, tools=effective_tools, - model=self.model, + runtime=self.llm_runtime(), max_iterations=self.max_iterations, max_tool_result_chars=self.max_tool_result_chars, hook=hook, @@ -872,7 +872,6 @@ class AgentLoop: concurrent_tools=True, workspace=effective_scope.project_path, session_key=session.key if session else None, - context_window_tokens=self.context_window_tokens, context_block_limit=self.context_block_limit, provider_retry_mode=self.provider_retry_mode, progress_callback=on_progress, diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3cb34ed1..4f252f47 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -30,6 +30,7 @@ from nanobot.utils.helpers import ( strip_reasoning_tags, strip_think, ) +from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.prompt_templates import render_template from nanobot.utils.runtime import ( EMPTY_FINAL_RESPONSE_MESSAGE, @@ -61,12 +62,9 @@ class AgentRunSpec: initial_messages: list[dict[str, Any]] tools: ToolRegistry - model: str + runtime: LLMRuntime max_iterations: int max_tool_result_chars: int - temperature: float | None = None - max_tokens: int | None = None - reasoning_effort: str | None = None hook: AgentHook | None = None error_message: str | None = _DEFAULT_ERROR_MESSAGE max_iterations_message: str | None = None @@ -74,7 +72,6 @@ class AgentRunSpec: fail_on_tool_error: bool = False workspace: Path | None = None session_key: str | None = None - context_window_tokens: int | None = None context_block_limit: int | None = None provider_retry_mode: str = "standard" progress_callback: Any | None = None @@ -105,8 +102,7 @@ class AgentRunResult: class AgentRunner: """Run a tool-capable LLM loop without product-layer concerns.""" - def __init__(self, provider: LLMProvider): - self.provider = provider + def __init__(self) -> None: self.context_governor = ContextGovernor() @staticmethod @@ -189,7 +185,7 @@ class AgentRunner: { "phase": "final_response", "iteration": iteration, - "model": spec.model, + "model": spec.runtime.model, "assistant_message": assistant_message, "completed_tool_results": [], "pending_tool_calls": [], @@ -344,15 +340,15 @@ class AgentRunner: injection_cycles = 0 compacted_tool_call_ids: set[str] = set() governance_config = ContextGovernanceConfig( - provider=self.provider, - model=spec.model, + provider=spec.runtime.provider, + model=spec.runtime.model, tools=spec.tools, workspace=spec.workspace, session_key=spec.session_key, max_tool_result_chars=spec.max_tool_result_chars, - context_window_tokens=spec.context_window_tokens, + context_window_tokens=spec.runtime.context_window_tokens, context_block_limit=spec.context_block_limit, - max_tokens=spec.max_tokens, + max_tokens=spec.runtime.generation.max_tokens, inflight_start_index=len(spec.initial_messages), ) @@ -429,7 +425,7 @@ class AgentRunner: { "phase": "awaiting_tools", "iteration": iteration, - "model": spec.model, + "model": spec.runtime.model, "assistant_message": assistant_message, "completed_tool_results": [], "pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls], @@ -491,7 +487,7 @@ class AgentRunner: { "phase": "tools_completed", "iteration": iteration, - "model": spec.model, + "model": spec.runtime.model, "assistant_message": assistant_message, "completed_tool_results": completed_tool_results, "pending_tool_calls": [], @@ -645,7 +641,7 @@ class AgentRunner: { "phase": "final_response", "iteration": iteration, - "model": spec.model, + "model": spec.runtime.model, "assistant_message": messages[-1], "completed_tool_results": [], "pending_tool_calls": [], @@ -702,16 +698,14 @@ class AgentRunner: kwargs: dict[str, Any] = { "messages": messages, "tools": tools, - "model": spec.model, + "model": spec.runtime.model, "retry_mode": spec.provider_retry_mode, "on_retry_wait": spec.retry_wait_callback, } - if spec.temperature is not None: - kwargs["temperature"] = spec.temperature - if spec.max_tokens is not None: - kwargs["max_tokens"] = spec.max_tokens - if spec.reasoning_effort is not None: - kwargs["reasoning_effort"] = spec.reasoning_effort + generation = spec.runtime.generation + kwargs["temperature"] = generation.temperature + kwargs["max_tokens"] = generation.max_tokens + kwargs["reasoning_effort"] = generation.reasoning_effort return kwargs async def _request_model( @@ -746,7 +740,7 @@ class AgentRunner: not wants_streaming and spec.stream_progress_deltas and spec.progress_callback is not None - and getattr(self.provider, "supports_progress_deltas", False) is True + and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True ) progress_state: dict[str, bool] | None = None @@ -774,7 +768,7 @@ class AgentRunner: async def _stream_recover() -> None: await hook.on_stream_end(context, resuming=True) - coro = self.provider.chat_stream_with_retry( + coro = spec.runtime.provider.chat_stream_with_retry( **kwargs, on_content_delta=_stream, on_thinking_delta=_thinking, @@ -805,12 +799,12 @@ class AgentRunner: context.streamed_content = True await spec.progress_callback(incremental) - coro = self.provider.chat_stream_with_retry( + coro = spec.runtime.provider.chat_stream_with_retry( **kwargs, on_content_delta=_stream_progress, ) else: - coro = self.provider.chat_with_retry(**kwargs) + coro = spec.runtime.provider.chat_with_retry(**kwargs) # Streaming requests already have provider-level idle timeouts # (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock @@ -985,7 +979,7 @@ class AgentRunner: messages: list[dict[str, Any]], ) -> LLMResponse: kwargs = self._build_request_kwargs(spec, messages, tools=None) - return await self.provider.chat_with_retry(**kwargs) + return await spec.runtime.provider.chat_with_retry(**kwargs) @staticmethod def _budget_exhausted_finalization_messages( @@ -1033,7 +1027,12 @@ class AgentRunner: tools = spec.tools.get_definitions() except Exception: tools = None - prompt_tokens, _ = estimate_prompt_tokens_chain(self.provider, spec.model, messages, tools) + prompt_tokens, _ = estimate_prompt_tokens_chain( + spec.runtime.provider, + spec.runtime.model, + messages, + tools, + ) assistant_message = build_assistant_message( response.content or "", tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls], diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 85dbce66..009b0598 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -26,6 +26,7 @@ from nanobot.security.workspace_access import ( reset_workspace_scope, workspace_sandbox_status, ) +from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.prompt_templates import render_template @@ -81,6 +82,7 @@ class SubagentManager: bus: MessageBus, max_tool_result_chars: int, model: str | None = None, + context_window_tokens: int | None = None, tools_config: ToolsConfig | None = None, restrict_to_workspace: bool = False, disabled_skills: list[str] | None = None, @@ -94,6 +96,7 @@ class SubagentManager: self.workspace = workspace self.bus = bus self.model = model or provider.get_default_model() + self.context_window_tokens = context_window_tokens or defaults.context_window_tokens self.tools_config = tools_config or ToolsConfig() self.max_tool_result_chars = max_tool_result_chars self.restrict_to_workspace = restrict_to_workspace @@ -113,7 +116,7 @@ class SubagentManager: if fail_on_tool_error is not None else defaults.fail_on_tool_error ) - self.runner = AgentRunner(provider) + self.runner = AgentRunner() self._llm_wall_timeout_for_session = llm_wall_timeout_for_session self._running_tasks: dict[str, asyncio.Task[None]] = {} self._task_statuses: dict[str, SubagentStatus] = {} @@ -149,10 +152,16 @@ class SubagentManager: ToolLoader().load(ctx, registry, scope="subagent") return registry - def set_provider(self, provider: LLMProvider, model: str) -> None: + def set_provider( + self, + provider: LLMProvider, + model: str, + context_window_tokens: int | None = None, + ) -> None: self.provider = provider self.model = model - self.runner.provider = provider + if context_window_tokens is not None: + self.context_window_tokens = context_window_tokens async def spawn( self, @@ -246,11 +255,15 @@ class SubagentManager: ) token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None try: + runtime = LLMRuntime.capture( + self.provider, + self.model, + context_window_tokens=self.context_window_tokens, + ).with_generation_overrides(temperature=temperature) result = await self.runner.run(AgentRunSpec( initial_messages=messages, tools=tools, - model=self.model, - temperature=temperature, + runtime=runtime, max_iterations=self.max_iterations, max_tool_result_chars=self.max_tool_result_chars, hook=_SubagentHook(task_id, status), diff --git a/nanobot/utils/llm_runtime.py b/nanobot/utils/llm_runtime.py index 2af2f15a..89fdf7f9 100644 --- a/nanobot/utils/llm_runtime.py +++ b/nanobot/utils/llm_runtime.py @@ -38,8 +38,8 @@ class LLMRuntime: snapshot_signature: tuple[object, ...] | None = None, ) -> LLMRuntime: """Capture provider defaults without retaining mutable generation state.""" - generation = provider.generation defaults = GenerationSettings() + generation = getattr(provider, "generation", defaults) return cls( provider=provider, model=model, diff --git a/tests/agent/runner_helpers.py b/tests/agent/runner_helpers.py new file mode 100644 index 00000000..db97a7f4 --- /dev/null +++ b/tests/agent/runner_helpers.py @@ -0,0 +1,54 @@ +"""Compatibility helpers while runner tests migrate to immutable runtimes.""" + +from __future__ import annotations + +from typing import Any + +from nanobot.agent.runner import AgentRunSpec +from nanobot.config.schema import AgentDefaults +from nanobot.providers.base import GenerationSettings, LLMProvider +from nanobot.utils.llm_runtime import LLMRuntime + + +def make_run_spec(provider: LLMProvider, **kwargs: Any) -> AgentRunSpec: + """Build a run spec from the pre-runtime test arguments. + + Keeping this translation in test support makes production's execution + contract strict while avoiding irrelevant setup noise in runner behavior + tests. New tests should pass ``runtime`` to ``AgentRunSpec`` directly when + runtime identity is itself under test. + """ + model = kwargs.pop("model") + context_window_tokens = kwargs.pop( + "context_window_tokens", + AgentDefaults().context_window_tokens, + ) + provider_generation = getattr(provider, "generation", None) + defaults = GenerationSettings() + + temperature = kwargs.pop("temperature", None) + if temperature is None: + candidate = getattr(provider_generation, "temperature", None) + temperature = candidate if isinstance(candidate, (int, float)) else defaults.temperature + + max_tokens = kwargs.pop("max_tokens", None) + if max_tokens is None: + candidate = getattr(provider_generation, "max_tokens", None) + max_tokens = candidate if isinstance(candidate, int) else defaults.max_tokens + + reasoning_effort = kwargs.pop("reasoning_effort", None) + if reasoning_effort is None: + candidate = getattr(provider_generation, "reasoning_effort", None) + reasoning_effort = candidate if isinstance(candidate, str) else None + + runtime = LLMRuntime( + provider=provider, + model=model, + generation=GenerationSettings( + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + ), + context_window_tokens=context_window_tokens, + ) + return AgentRunSpec(runtime=runtime, **kwargs) diff --git a/tests/agent/test_runner_core.py b/tests/agent/test_runner_core.py index 1119930c..752c3bd1 100644 --- a/tests/agent/test_runner_core.py +++ b/tests/agent/test_runner_core.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest @@ -17,7 +18,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars @pytest.mark.asyncio async def test_runner_preserves_reasoning_fields_and_tool_results(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) captured_second_call: list[dict] = [] @@ -41,8 +42,8 @@ async def test_runner_preserves_reasoning_fields_and_tool_results(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="tool result") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "do task"}, @@ -74,7 +75,7 @@ async def test_runner_preserves_reasoning_fields_and_tool_results(): @pytest.mark.asyncio async def test_runner_returns_max_iterations_fallback(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -85,8 +86,8 @@ async def test_runner_returns_max_iterations_fallback(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="tool result") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -108,7 +109,7 @@ async def test_runner_returns_max_iterations_fallback(): @pytest.mark.asyncio async def test_runner_uses_no_tools_finalization_after_max_iterations(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) calls: list[dict] = [] @@ -137,8 +138,8 @@ async def test_runner_uses_no_tools_finalization_after_max_iterations(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="tool result") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "inspect the repo"}], tools=tools, model="test-model", @@ -160,7 +161,7 @@ async def test_runner_uses_no_tools_finalization_after_max_iterations(): @pytest.mark.asyncio async def test_runner_times_out_hung_llm_request(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -171,9 +172,9 @@ async def test_runner_times_out_hung_llm_request(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) + runner = AgentRunner() started = time.monotonic() - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -190,7 +191,7 @@ async def test_runner_times_out_hung_llm_request(): @pytest.mark.asyncio async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) streamed: list[str] = [] @@ -214,10 +215,10 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): async def on_stream(self, context: AgentHookContext, delta: str) -> None: streamed.append(delta) - runner = AgentRunner(provider) + runner = AgentRunner() wait_for = AsyncMock(side_effect=AssertionError("streaming path must not use wait_for")) with patch("nanobot.agent.runner.asyncio.wait_for", wait_for): - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "think for a while"}], tools=tools, model="test-model", @@ -236,7 +237,7 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): @pytest.mark.asyncio async def test_runner_replaces_empty_tool_result_with_marker(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) captured_second_call: list[dict] = [] @@ -258,8 +259,8 @@ async def test_runner_replaces_empty_tool_result_with_marker(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -275,7 +276,7 @@ async def test_runner_replaces_empty_tool_result_with_marker(): @pytest.mark.asyncio async def test_runner_retries_empty_final_response_with_summary_prompt(): """Empty responses get 2 silent retries before finalization kicks in.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) calls: list[dict] = [] @@ -298,8 +299,8 @@ async def test_runner_retries_empty_final_response_with_summary_prompt(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -320,7 +321,7 @@ async def test_runner_retries_empty_final_response_with_summary_prompt(): @pytest.mark.asyncio async def test_runner_uses_specific_message_after_empty_finalization_retry(): """After silent retries + finalization all return empty, stop_reason is empty_final_response.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE provider = MagicMock(spec=LLMProvider) @@ -332,8 +333,8 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -352,7 +353,7 @@ async def test_runner_empty_response_does_not_break_tool_chain(): Sequence: tool_call -> empty -> tool_call -> final text. The runner should recover via silent retry and complete normally. """ - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) call_count = 0 @@ -390,8 +391,8 @@ async def test_runner_empty_response_does_not_break_tool_chain(): tool_registry.get_definitions.return_value = [{"type": "function", "function": {"name": "read_file"}}] tool_registry.execute = AsyncMock(side_effect=fake_tool) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "read both files"}], tools=tool_registry, model="test-model", @@ -409,7 +410,7 @@ async def test_runner_empty_response_does_not_break_tool_chain(): async def test_runner_accumulates_usage_and_preserves_cached_tokens(): """Runner should accumulate prompt/completion tokens across iterations and preserve cached_tokens from provider responses.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) call_count = {"n": 0} @@ -433,8 +434,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="file content") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -456,7 +457,7 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): internal retry diagnostics like "Model request failed, retry in 1s" to leak to end-user channels as normal progress updates. """ - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner captured: dict = {} @@ -472,8 +473,8 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): progress_cb = AsyncMock() retry_wait_cb = AsyncMock() - runner = AgentRunner(provider) - await runner.run(AgentRunSpec( + runner = AgentRunner() + await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "hi"}, @@ -498,7 +499,7 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress(): @pytest.mark.asyncio async def test_runner_passes_temperature_to_provider(): """temperature from AgentRunSpec should reach provider.chat_with_retry.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner captured: dict = {} @@ -511,8 +512,8 @@ async def test_runner_passes_temperature_to_provider(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - await runner.run(AgentRunSpec( + runner = AgentRunner() + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -527,7 +528,7 @@ async def test_runner_passes_temperature_to_provider(): @pytest.mark.asyncio async def test_runner_passes_max_tokens_to_provider(): """max_tokens from AgentRunSpec should reach provider.chat_with_retry.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner captured: dict = {} @@ -540,8 +541,8 @@ async def test_runner_passes_max_tokens_to_provider(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - await runner.run(AgentRunSpec( + runner = AgentRunner() + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -556,7 +557,7 @@ async def test_runner_passes_max_tokens_to_provider(): @pytest.mark.asyncio async def test_runner_passes_reasoning_effort_to_provider(): """reasoning_effort from AgentRunSpec should reach provider.chat_with_retry.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner captured: dict = {} @@ -569,8 +570,8 @@ async def test_runner_passes_reasoning_effort_to_provider(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - await runner.run(AgentRunSpec( + runner = AgentRunner() + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_errors.py b/tests/agent/test_runner_errors.py index 43dfe684..fffbbac2 100644 --- a/tests/agent/test_runner_errors.py +++ b/tests/agent/test_runner_errors.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest @@ -15,7 +16,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars @pytest.mark.asyncio async def test_runner_returns_structured_tool_error(): - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -26,9 +27,9 @@ async def test_runner_returns_structured_tool_error(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(side_effect=RuntimeError("boom")) - runner = AgentRunner(provider) + runner = AgentRunner() - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -49,9 +50,8 @@ async def test_llm_error_not_appended_to_session_messages(): """When LLM returns finish_reason='error', the error content must NOT be appended to the messages list (prevents polluting session history).""" from nanobot.agent.runner import ( - AgentRunSpec, - AgentRunner, _PERSISTED_MODEL_ERROR_PLACEHOLDER, + AgentRunner, ) provider = MagicMock(spec=LLMProvider) @@ -61,8 +61,8 @@ async def test_llm_error_not_appended_to_session_messages(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -81,7 +81,7 @@ async def test_llm_error_not_appended_to_session_messages(): @pytest.mark.asyncio async def test_llm_arrearage_error_surfaces_clear_message(): """Arrearage errors yield a clear user-facing message, not a raw dump (#3006).""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner, _ARREARAGE_ERROR_MESSAGE + from nanobot.agent.runner import _ARREARAGE_ERROR_MESSAGE, AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -90,8 +90,8 @@ async def test_llm_arrearage_error_surfaces_clear_message(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -117,7 +117,7 @@ async def test_runner_ignores_tool_calls_when_finish_reason_blocks_execution( expected_stop_reason: str, ): """Provider/gateway-injected tool calls under terminal block reasons must not run.""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -130,7 +130,7 @@ async def test_runner_ignores_tool_calls_when_finish_reason_blocks_execution( tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="should not run") - result = await AgentRunner(provider).run(AgentRunSpec( + result = await AgentRunner().run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "run a command"}], tools=tools, model="test-model", @@ -147,7 +147,7 @@ async def test_runner_ignores_tool_calls_when_finish_reason_blocks_execution( @pytest.mark.asyncio async def test_runner_tool_error_sets_final_content(): - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -163,8 +163,8 @@ async def test_runner_tool_error_sets_final_content(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(side_effect=RuntimeError("boom")) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -179,7 +179,7 @@ async def test_runner_tool_error_sets_final_content(): @pytest.mark.asyncio async def test_runner_preserves_successful_exec_output_that_starts_with_error(): - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -200,8 +200,8 @@ async def test_runner_preserves_successful_exec_output_that_starts_with_error(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value=output) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "run report"}], tools=tools, model="test-model", @@ -221,7 +221,7 @@ async def test_runner_preserves_successful_exec_output_that_starts_with_error(): async def test_runner_tool_error_preserves_tool_results_in_messages(): """When a tool raises a fatal error, its results must still be appended to messages so the session never contains orphan tool_calls (#2943).""" - from nanobot.agent.runner import AgentRunSpec, AgentRunner + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -251,8 +251,8 @@ async def test_runner_tool_error_preserves_tool_results_in_messages(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(side_effect=fake_execute) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do stuff"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_goal_continue.py b/tests/agent/test_runner_goal_continue.py index cca146a0..db86f24d 100644 --- a/tests/agent/test_runner_goal_continue.py +++ b/tests/agent/test_runner_goal_continue.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider, LLMResponse @@ -20,7 +21,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars @pytest.mark.asyncio async def test_runner_exits_normally_without_predicate(): """Baseline: no predicate, runner exits with completed on final text.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -29,8 +30,8 @@ async def test_runner_exits_normally_without_predicate(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -45,7 +46,7 @@ async def test_runner_exits_normally_without_predicate(): @pytest.mark.asyncio async def test_runner_exits_normally_with_inactive_goal(): """Predicate returns False, runner should exit normally.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -54,8 +55,8 @@ async def test_runner_exits_normally_with_inactive_goal(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -77,7 +78,7 @@ async def test_runner_forces_continue_when_goal_active(): "completed". With the fix the runner is forced to continue until max_iterations is hit. """ - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -86,8 +87,8 @@ async def test_runner_forces_continue_when_goal_active(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -107,7 +108,7 @@ async def test_runner_forces_continue_when_goal_active(): @pytest.mark.asyncio async def test_runner_respects_max_iterations_even_with_active_goal(): """A single iteration with active goal still hits max_iterations.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -116,8 +117,8 @@ async def test_runner_respects_max_iterations_even_with_active_goal(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -132,7 +133,7 @@ async def test_runner_respects_max_iterations_even_with_active_goal(): @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 + from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -142,8 +143,8 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap(): tools.get_definitions.return_value = [] max_iterations = _MAX_INJECTION_CYCLES + 3 - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -160,7 +161,7 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap(): @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".""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -170,8 +171,8 @@ async def test_runner_does_not_force_continue_on_error(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -186,7 +187,7 @@ async def test_runner_does_not_force_continue_on_error(): @pytest.mark.asyncio async def test_runner_uses_custom_goal_continue_message(): """Custom goal_continue_message should be injected instead of the default.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -197,8 +198,8 @@ async def test_runner_uses_custom_goal_continue_message(): custom_msg = "CUSTOM_CONTINUE_PLEASE" - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -215,7 +216,7 @@ async def test_runner_uses_custom_goal_continue_message(): @pytest.mark.asyncio async def test_runner_resolves_goal_continue_message_lazily(): """The continuation text can depend on goal metadata created during the run.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) provider.chat_with_retry = AsyncMock(return_value=LLMResponse( @@ -229,8 +230,8 @@ async def test_runner_resolves_goal_continue_message_lazily(): calls["n"] += 1 return "Goal (active):\nWrite the article draft." - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_governance.py b/tests/agent/test_runner_governance.py index c2b2a0b2..7b22740f 100644 --- a/tests/agent/test_runner_governance.py +++ b/tests/agent/test_runner_governance.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent.runner_helpers import make_run_spec from nanobot.agent.context_governance import ( BACKFILL_CONTENT, MICROCOMPACT_KEEP_RECENT, @@ -29,14 +30,14 @@ def _governance_config( ) -> ContextGovernanceConfig: return ContextGovernanceConfig( provider=provider, - model=spec.model, + model=spec.runtime.model, tools=tools, workspace=spec.workspace, session_key=spec.session_key, max_tool_result_chars=spec.max_tool_result_chars, - context_window_tokens=spec.context_window_tokens, + context_window_tokens=spec.runtime.context_window_tokens, context_block_limit=spec.context_block_limit, - max_tokens=spec.max_tokens, + max_tokens=spec.runtime.generation.max_tokens, inflight_start_index=inflight_start_index, ) @@ -75,11 +76,11 @@ async def test_runner_uses_raw_messages_when_context_governance_fails(): {"role": "user", "content": "hello"}, ] - runner = AgentRunner(provider) + runner = AgentRunner() runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign] side_effect=RuntimeError("boom") ) - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=initial_messages, tools=tools, model="test-model", @@ -106,7 +107,7 @@ def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch {"role": "tool", "tool_call_id": "call_1", "content": "tool output"}, {"role": "assistant", "content": "after tool"}, ] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -153,7 +154,7 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch): {"role": "assistant", "content": "recent answer"}, {"role": "user", "content": "recent two"}, ] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -281,8 +282,8 @@ async def test_runner_drops_orphan_tool_results_before_model_request(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "old user"}, @@ -423,8 +424,8 @@ async def test_runner_backfill_only_mutates_model_context_not_returned_messages( {"role": "user", "content": "new prompt"}, ] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=initial_messages, tools=tools, model="test-model", @@ -503,7 +504,7 @@ def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch): total = MICROCOMPACT_KEEP_RECENT + 5 long_content = "x" * 600 messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -537,7 +538,7 @@ def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch): total = MICROCOMPACT_KEEP_RECENT + 8 long_content = "x" * 600 messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -581,7 +582,7 @@ def test_microcompact_compacts_newest_when_it_alone_overflows(monkeypatch): long_content = "x" * 600 messages = _microcompact_messages(total=1, tool_name="read_file", content=long_content) - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -622,7 +623,7 @@ def test_context_governor_keeps_compaction_boundary_stable(monkeypatch): total = MICROCOMPACT_KEEP_RECENT + 8 long_content = "x" * 600 messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -662,7 +663,7 @@ def test_microcompact_preserves_short_results(monkeypatch): total = MICROCOMPACT_KEEP_RECENT + 5 messages = _microcompact_messages(total=total, tool_name="exec", content="short") - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -695,7 +696,7 @@ def test_microcompact_skips_non_compactable_tools(monkeypatch): total = MICROCOMPACT_KEEP_RECENT + 5 long_content = "y" * 1000 messages = _microcompact_messages(total=total, tool_name="message", content=long_content) - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -789,7 +790,7 @@ def test_snip_history_preserves_user_message_after_truncation(monkeypatch): {"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"}, ] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", @@ -843,7 +844,7 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch): {"role": "tool", "tool_call_id": "tc_2", "content": "result 2"}, ] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=messages, tools=tools, model="test-model", diff --git a/tests/agent/test_runner_hooks.py b/tests/agent/test_runner_hooks.py index a61157d6..f441d285 100644 --- a/tests/agent/test_runner_hooks.py +++ b/tests/agent/test_runner_hooks.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest @@ -16,7 +17,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars @pytest.mark.asyncio async def test_runner_calls_hooks_in_order(): from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) call_count = {"n": 0} @@ -67,8 +68,8 @@ async def test_runner_calls_hooks_in_order(): events.append(("finalize_content", context.iteration, content)) return content.upper() if content else content - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -100,7 +101,7 @@ async def test_runner_calls_hooks_in_order(): @pytest.mark.asyncio async def test_runner_streaming_hook_receives_deltas_and_end_signal(): from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) streamed: list[str] = [] @@ -126,8 +127,8 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal(): async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: endings.append(resuming) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -146,7 +147,7 @@ async def test_runner_streaming_hook_receives_deltas_and_end_signal(): async def test_runner_passes_cached_tokens_to_hook_context(): """Hook context.usage should contain cached_tokens.""" from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) captured_usage: list[dict] = [] @@ -166,8 +167,8 @@ async def test_runner_passes_cached_tokens_to_hook_context(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - await runner.run(AgentRunSpec( + runner = AgentRunner() + await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -184,7 +185,7 @@ async def test_runner_passes_cached_tokens_to_hook_context(): @pytest.mark.asyncio async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch): from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) captured_usage: list[dict] = [] @@ -205,8 +206,8 @@ async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch): ) monkeypatch.setattr("nanobot.agent.runner.estimate_message_tokens", lambda message: 7) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -225,7 +226,7 @@ async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch): @pytest.mark.asyncio async def test_runner_calls_run_level_hooks_on_success(): from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) events: list[tuple] = [] @@ -263,8 +264,8 @@ async def test_runner_calls_run_level_hooks_on_success(): async def on_finally(self, context: AgentRunHookContext) -> None: events.append(("on_finally", context.stop_reason, context.exception)) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -297,7 +298,7 @@ async def test_runner_calls_run_level_hooks_on_success(): @pytest.mark.asyncio async def test_runner_run_level_context_is_detached_snapshot(): from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) call_count = {"n": 0} @@ -330,8 +331,8 @@ async def test_runner_run_level_context_is_detached_snapshot(): async def on_finally(self, context: AgentRunHookContext) -> None: context.messages[0]["content"] = "mutated-finally" - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -351,7 +352,7 @@ async def test_runner_run_level_context_is_detached_snapshot(): @pytest.mark.asyncio async def test_runner_calls_on_error_for_model_error_result(): from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) events: list[tuple] = [] @@ -376,8 +377,8 @@ async def test_runner_calls_on_error_for_model_error_result(): async def on_finally(self, context: AgentRunHookContext) -> None: events.append(("on_finally", context.stop_reason, context.error)) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -399,7 +400,7 @@ async def test_runner_calls_on_error_for_model_error_result(): @pytest.mark.asyncio async def test_runner_calls_on_error_and_finally_for_unhandled_exception(): from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) events: list[tuple] = [] @@ -429,9 +430,9 @@ async def test_runner_calls_on_error_and_finally_for_unhandled_exception(): async def on_finally(self, context: AgentRunHookContext) -> None: events.append(("on_finally", context.stop_reason)) - runner = AgentRunner(provider) + runner = AgentRunner() with pytest.raises(RuntimeError, match="provider exploded"): - await runner.run(AgentRunSpec( + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -450,7 +451,7 @@ async def test_runner_calls_on_error_and_finally_for_unhandled_exception(): @pytest.mark.asyncio async def test_runner_preserves_original_exception_when_finally_hook_fails(): from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -465,9 +466,9 @@ async def test_runner_preserves_original_exception_when_finally_hook_fails(): async def on_finally(self, context: AgentRunHookContext) -> None: raise RuntimeError("finally exploded") - runner = AgentRunner(provider) + runner = AgentRunner() with pytest.raises(RuntimeError, match="provider exploded"): - await runner.run(AgentRunSpec( + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -482,7 +483,7 @@ async def test_runner_does_not_report_cancellation_as_error(): import asyncio from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) events: list[tuple] = [] @@ -512,9 +513,9 @@ async def test_runner_does_not_report_cancellation_as_error(): type(context.exception).__name__ if context.exception else None, )) - runner = AgentRunner(provider) + runner = AgentRunner() with pytest.raises(asyncio.CancelledError): - await runner.run(AgentRunSpec( + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -534,7 +535,7 @@ async def test_runner_preserves_cancellation_when_finally_hook_fails(): import asyncio from nanobot.agent.hook import AgentHook, AgentRunHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock(spec=LLMProvider) @@ -549,9 +550,9 @@ async def test_runner_preserves_cancellation_when_finally_hook_fails(): async def on_finally(self, context: AgentRunHookContext) -> None: raise RuntimeError("finally exploded") - runner = AgentRunner(provider) + runner = AgentRunner() with pytest.raises(asyncio.CancelledError): - await runner.run(AgentRunSpec( + await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 9311f5eb..e78e8dd6 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -42,13 +43,13 @@ def _make_loop(tmp_path): @pytest.mark.asyncio async def test_drain_injections_returns_empty_when_no_callback(): """No injection_callback → empty list.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=None, @@ -60,11 +61,11 @@ async def test_drain_injections_returns_empty_when_no_callback(): @pytest.mark.asyncio async def test_drain_injections_extracts_content_from_inbound_messages(): """Should extract .content from InboundMessage objects.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] @@ -76,7 +77,7 @@ async def test_drain_injections_extracts_content_from_inbound_messages(): async def cb(): return msgs - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -91,11 +92,11 @@ async def test_drain_injections_extracts_content_from_inbound_messages(): @pytest.mark.asyncio async def test_drain_injections_passes_limit_to_callback_when_supported(): """Limit-aware callbacks can preserve overflow in their own queue.""" - from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec + from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] seen_limits: list[int] = [] @@ -109,7 +110,7 @@ async def test_drain_injections_passes_limit_to_callback_when_supported(): seen_limits.append(limit) return msgs[:limit] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -126,11 +127,11 @@ async def test_drain_injections_passes_limit_to_callback_when_supported(): @pytest.mark.asyncio async def test_drain_injections_skips_empty_content(): """Messages with blank content should be filtered out.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] @@ -143,7 +144,7 @@ async def test_drain_injections_skips_empty_content(): async def cb(): return msgs - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -155,10 +156,10 @@ async def test_drain_injections_skips_empty_content(): @pytest.mark.asyncio async def test_drain_injections_filters_empty_dict_payloads(): """Pre-normalized dict injections should obey the same empty-content guard.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] @@ -176,7 +177,7 @@ async def test_drain_injections_filters_empty_dict_payloads(): async def cb(): return msgs - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -193,10 +194,10 @@ async def test_drain_injections_skips_objects_with_none_content(): """Objects exposing content=None should be skipped rather than stringified.""" from types import SimpleNamespace - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] @@ -207,7 +208,7 @@ async def test_drain_injections_skips_objects_with_none_content(): SimpleNamespace(content="valid"), ] - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -219,17 +220,17 @@ async def test_drain_injections_skips_objects_with_none_content(): @pytest.mark.asyncio async def test_drain_injections_handles_callback_exception(): """If the callback raises, return empty list (error is logged).""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() - runner = AgentRunner(provider) + runner = AgentRunner() tools = MagicMock() tools.get_definitions.return_value = [] async def cb(): raise RuntimeError("boom") - spec = AgentRunSpec( + spec = make_run_spec(provider, initial_messages=[], tools=tools, model="m", max_iterations=1, max_tool_result_chars=1000, injection_callback=cb, @@ -241,7 +242,7 @@ async def test_drain_injections_handles_callback_exception(): @pytest.mark.asyncio async def test_checkpoint1_injects_after_tool_execution(): """Follow-up messages are injected after tool execution, before next LLM call.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -272,8 +273,8 @@ async def test_checkpoint1_injects_after_tool_execution(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -295,7 +296,7 @@ async def test_checkpoint1_injects_after_tool_execution(): async def test_checkpoint2_injects_after_final_response_with_resuming_stream(): """After final response, if injections exist, stream_end should get resuming=True.""" from nanobot.agent.hook import AgentHook, AgentHookContext - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -330,8 +331,8 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="quick follow-up") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -353,7 +354,7 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream(): @pytest.mark.asyncio async def test_checkpoint2_preserves_final_response_in_history_before_followup(): """A follow-up injected after a final answer must still see that answer in history.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -378,8 +379,8 @@ async def test_checkpoint2_preserves_final_response_in_history_before_followup() InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -530,7 +531,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p @pytest.mark.asyncio async def test_runner_merges_multiple_injected_user_messages_without_losing_media(): """Multiple injected follow-ups should not create lossy consecutive user messages.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() call_count = {"n": 0} @@ -561,8 +562,8 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi ] return [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -593,7 +594,7 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi @pytest.mark.asyncio async def test_injection_cycles_capped_at_max(): """Injection cycles should be capped at _MAX_INJECTION_CYCLES.""" - from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec + from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -616,8 +617,8 @@ async def test_injection_cycles_capped_at_max(): return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")] return [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "start"}], tools=tools, model="test-model", @@ -634,7 +635,7 @@ async def test_injection_cycles_capped_at_max(): @pytest.mark.asyncio async def test_no_injections_flag_is_false_by_default(): """had_injections should be False when no injection callback or no messages.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -645,8 +646,8 @@ async def test_no_injections_flag_is_false_by_default(): tools = MagicMock() tools.get_definitions.return_value = [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hi"}], tools=tools, model="test-model", @@ -1089,7 +1090,7 @@ async def test_dispatch_republishes_leftover_queue_messages(tmp_path): @pytest.mark.asyncio async def test_drain_injections_on_fatal_tool_error(): """Pending injections should be drained even when a fatal tool error occurs.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1118,8 +1119,8 @@ async def test_drain_injections_on_fatal_tool_error(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after error") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -1142,7 +1143,7 @@ async def test_drain_injections_on_fatal_tool_error(): @pytest.mark.asyncio async def test_drain_injections_on_llm_error(): """Pending injections should be drained when the LLM returns an error finish_reason.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1171,8 +1172,8 @@ async def test_drain_injections_on_llm_error(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after LLM error") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "previous response"}, @@ -1197,7 +1198,7 @@ async def test_drain_injections_on_llm_error(): @pytest.mark.asyncio async def test_drain_injections_on_empty_final_response(): """Pending injections should be drained when the runner exits due to empty response.""" - from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner, AgentRunSpec + from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1221,8 +1222,8 @@ async def test_drain_injections_on_empty_final_response(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after empty") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "previous response"}, @@ -1252,7 +1253,7 @@ async def test_drain_injections_on_max_iterations(): injections are appended to messages but not processed by the LLM. The key point is they are consumed from the queue to prevent re-publish. """ - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1278,8 +1279,8 @@ async def test_drain_injections_on_max_iterations(): InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after max iters") ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -1304,7 +1305,7 @@ async def test_drain_injections_on_max_iterations(): async def test_drain_injections_set_flag_when_followup_arrives_after_last_iteration(): """Late follow-ups drained in max_iterations should still flip had_injections.""" from nanobot.agent.hook import AgentHook - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1342,8 +1343,8 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat ) ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "hello"}], tools=tools, model="test-model", @@ -1366,7 +1367,7 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat @pytest.mark.asyncio async def test_injection_cycle_cap_on_error_path(): """Injection cycles should be capped even when every iteration hits an LLM error.""" - from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec + from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner from nanobot.bus.events import InboundMessage provider = MagicMock() @@ -1393,8 +1394,8 @@ async def test_injection_cycle_cap_on_error_path(): return [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")] return [] - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "previous"}, diff --git a/tests/agent/test_runner_persistence.py b/tests/agent/test_runner_persistence.py index 24ac1051..975c787a 100644 --- a/tests/agent/test_runner_persistence.py +++ b/tests/agent/test_runner_persistence.py @@ -6,13 +6,14 @@ import os import time from unittest.mock import AsyncMock, MagicMock, patch +from agent.runner_helpers import make_run_spec from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() captured_second_call: list[dict] = [] @@ -34,8 +35,8 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="x" * 20_000) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", @@ -125,7 +126,7 @@ def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path): async def test_read_file_result_is_not_offloaded(tmp_path): """read_file must not trigger generic offloading (prevents persist->read->persist loops).""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() captured_second_call: list[dict] = [] @@ -147,8 +148,8 @@ async def test_read_file_result_is_not_offloaded(tmp_path): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="x" * 20_000) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "read big file"}], tools=tools, model="test-model", @@ -170,7 +171,7 @@ async def test_read_file_result_is_not_offloaded(tmp_path): async def test_runner_keeps_going_when_tool_result_persistence_fails(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() captured_second_call: list[dict] = [] @@ -192,12 +193,12 @@ async def test_runner_keeps_going_when_tool_result_persistence_fails(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="tool result") - runner = AgentRunner(provider) + runner = AgentRunner() with patch( "nanobot.agent.context_governance.maybe_persist_tool_result", side_effect=RuntimeError("disk full"), ): - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "do task"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_progress_deltas.py b/tests/agent/test_runner_progress_deltas.py index bbcb9329..f14a17ce 100644 --- a/tests/agent/test_runner_progress_deltas.py +++ b/tests/agent/test_runner_progress_deltas.py @@ -5,8 +5,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent.runner_helpers import make_run_spec from nanobot.agent.hooks import FileEditActivityHook -from nanobot.agent.runner import AgentRunner, AgentRunSpec +from nanobot.agent.runner import AgentRunner from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -27,8 +28,8 @@ async def test_runner_can_disable_provider_progress_delta_streaming(): tools.get_definitions.return_value = [] progress_cb = AsyncMock() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "hi"}, @@ -64,8 +65,8 @@ async def test_runner_streams_provider_progress_deltas_by_default(): tools.get_definitions.return_value = [] progress_cb = AsyncMock() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "hi"}, @@ -124,8 +125,8 @@ async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_pa provider.chat_with_retry = AsyncMock() tools = Tools() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "write a large file"}], tools=tools, model="test-model", @@ -198,8 +199,8 @@ async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_pat provider.chat_with_retry = AsyncMock() tools = Tools() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "edit a file"}], tools=tools, model="test-model", @@ -264,8 +265,8 @@ async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path) provider.chat_with_retry = AsyncMock() tools = Tools() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "write a file"}], tools=tools, model="test-model", @@ -328,8 +329,8 @@ async def test_runner_marks_file_edit_activity_failed_when_cancelled(tmp_path): provider.chat_with_retry = AsyncMock() tools = Tools() - runner = AgentRunner(provider) - task = asyncio.create_task(runner.run(AgentRunSpec( + runner = AgentRunner() + task = asyncio.create_task(runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "write a file"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_reasoning.py b/tests/agent/test_runner_reasoning.py index 2dbd46d1..048d715e 100644 --- a/tests/agent/test_runner_reasoning.py +++ b/tests/agent/test_runner_reasoning.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent.runner_helpers import make_run_spec from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -38,7 +39,7 @@ class _RecordingHook(AgentHook): async def test_runner_preserves_reasoning_fields_in_assistant_history(): """Reasoning fields ride along on the persisted assistant message so follow-up provider calls retain the model's prior thinking context.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() captured_second_call: list[dict] = [] @@ -62,8 +63,8 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="tool result") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[ {"role": "system", "content": "system"}, {"role": "user", "content": "do task"}, @@ -86,7 +87,7 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history(): @pytest.mark.asyncio async def test_runner_emits_anthropic_thinking_blocks(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -106,8 +107,8 @@ async def test_runner_emits_anthropic_thinking_blocks(): tools.get_definitions.return_value = [] hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], tools=tools, model="test-model", @@ -126,7 +127,7 @@ async def test_runner_emits_anthropic_thinking_blocks(): async def test_runner_emits_inline_think_content_as_reasoning(): """Models embedding reasoning in ... blocks should have that content extracted and emitted, and stripped from the answer.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -142,8 +143,8 @@ async def test_runner_emits_inline_think_content_as_reasoning(): tools.get_definitions.return_value = [] hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "what is the answer?"}], tools=tools, model="test-model", @@ -161,7 +162,7 @@ async def test_runner_emits_inline_think_content_as_reasoning(): async def test_runner_prefers_reasoning_content_over_inline_think(): """Fallback priority: dedicated reasoning_content wins; inline is still scrubbed from the answer content.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -178,8 +179,8 @@ async def test_runner_prefers_reasoning_content_over_inline_think(): tools.get_definitions.return_value = [] hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], tools=tools, model="test-model", @@ -197,7 +198,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): """`reasoning_content` arrives only on the final response; streaming the answer must not suppress it (the answer stream and the reasoning channel are independent — only the reasoning-already-emitted bit matters).""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() provider.supports_progress_deltas = True @@ -223,8 +224,8 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): progress_calls.append(content) hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], tools=tools, model="test-model", @@ -244,7 +245,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed(): async def test_runner_does_not_double_emit_when_inline_think_already_streamed(): """Inline `` blocks streamed incrementally during the answer stream must not be re-emitted from the final response.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() provider.supports_progress_deltas = True @@ -267,8 +268,8 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed(): pass hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "question"}], tools=tools, model="test-model", @@ -289,7 +290,7 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response(): """A non-streaming response carrying ``reasoning_content`` must emit both a reasoning delta and an end marker so channels can finalize the in-place bubble.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -306,8 +307,8 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response(): tools.get_definitions.return_value = [] hook = _RecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "q"}], tools=tools, model="test-model", @@ -333,7 +334,7 @@ class _StreamRecordingHook(_RecordingHook): async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): """Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``; final ``thinking_blocks`` must not emit again when already streamed.""" - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -357,8 +358,8 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): tools.get_definitions.return_value = [] hook = _StreamRecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "q"}], tools=tools, model="test-model", @@ -373,7 +374,7 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): @pytest.mark.asyncio async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -393,8 +394,8 @@ async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): tools.get_definitions.return_value = [] hook = _StreamRecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "q"}], tools=tools, model="test-model", @@ -409,7 +410,7 @@ async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): @pytest.mark.asyncio async def test_runner_ignores_empty_thinking_marker_before_final_reasoning(): - from nanobot.agent.runner import AgentRunner, AgentRunSpec + from nanobot.agent.runner import AgentRunner provider = MagicMock() @@ -432,8 +433,8 @@ async def test_runner_ignores_empty_thinking_marker_before_final_reasoning(): tools.get_definitions.return_value = [] hook = _StreamRecordingHook() - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "q"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_runtime_identity.py b/tests/agent/test_runner_runtime_identity.py index 45075dfc..8458125d 100644 --- a/tests/agent/test_runner_runtime_identity.py +++ b/tests/agent/test_runner_runtime_identity.py @@ -4,25 +4,43 @@ import pytest from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.config.schema import AgentDefaults -from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from nanobot.providers.base import ( + GenerationSettings, + LLMProvider, + LLMResponse, + ToolCallRequest, +) +from nanobot.utils.llm_runtime import LLMRuntime @pytest.mark.asyncio -@pytest.mark.xfail( - strict=True, - reason="AgentRunner reads its mutable provider again between model iterations", -) async def test_active_run_keeps_provider_captured_at_admission() -> None: first_provider = MagicMock(spec=LLMProvider) second_provider = MagicMock(spec=LLMProvider) + first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048) + second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512) first_calls = 0 second_calls = 0 - runner = AgentRunner(first_provider) + request_temperatures: list[float] = [] + selected_runtime = LLMRuntime.capture( + first_provider, + "captured-model", + context_window_tokens=16_384, + ) + runner = AgentRunner() - async def first_chat(**_kwargs): - nonlocal first_calls + async def first_chat(**kwargs): + nonlocal first_calls, selected_runtime first_calls += 1 - runner.provider = second_provider + request_temperatures.append(kwargs["temperature"]) + selected_runtime = LLMRuntime.capture( + second_provider, + "future-model", + context_window_tokens=8192, + ) + first_provider.generation = GenerationSettings(temperature=0.7, max_tokens=128) + if first_calls > 1: + return LLMResponse(content="done") return LLMResponse( content="working", tool_calls=[ToolCallRequest(id="call-1", name="read_file", arguments={})], @@ -42,10 +60,12 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None: await runner.run(AgentRunSpec( initial_messages=[{"role": "user", "content": "read it"}], tools=tools, - model="captured-model", + runtime=selected_runtime, max_iterations=2, max_tool_result_chars=AgentDefaults().max_tool_result_chars, )) assert first_calls == 2 assert second_calls == 0 + assert request_temperatures == [0.2, 0.2] + assert selected_runtime.provider is second_provider diff --git a/tests/agent/test_runner_safety.py b/tests/agent/test_runner_safety.py index 1fc467db..3819a3e5 100644 --- a/tests/agent/test_runner_safety.py +++ b/tests/agent/test_runner_safety.py @@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from nanobot.agent.runner import AgentRunner, AgentRunSpec +from agent.runner_helpers import make_run_spec +from nanobot.agent.runner import AgentRunner from nanobot.agent.tools import ToolResult from nanobot.config.schema import AgentDefaults from nanobot.providers.base import LLMResponse, ToolCallRequest @@ -40,9 +41,9 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore(): ) ) - runner = AgentRunner(provider) + runner = AgentRunner() - result = await runner.run(AgentRunSpec( + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -107,8 +108,8 @@ async def test_runner_returns_non_retryable_hint_on_ssrf_violation(): "Error: Command blocked by safety guard (internal/private URL detected)" )) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -162,8 +163,8 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside(): ) ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -214,8 +215,8 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts(): ) ) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", diff --git a/tests/agent/test_runner_tool_execution.py b/tests/agent/test_runner_tool_execution.py index 328bf8dd..57f6bfa0 100644 --- a/tests/agent/test_runner_tool_execution.py +++ b/tests/agent/test_runner_tool_execution.py @@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.agent.runner import AgentRunner, AgentRunSpec +from agent.runner_helpers import make_run_spec +from nanobot.agent.runner import AgentRunner from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.loader import ToolLoader @@ -117,7 +118,7 @@ async def _run_optional_tool_response(response: LLMResponse): shared_events=shared_events, )) - result = await AgentRunner(provider).run(AgentRunSpec( + result = await AgentRunner().run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "try optional"}], tools=tools, model="test-model", @@ -159,9 +160,10 @@ async def test_runner_batches_read_only_tools_before_exclusive_work(): tools.register(read_b) tools.register(write_a) - runner = AgentRunner(MagicMock()) + provider = MagicMock() + runner = AgentRunner() await runner._execute_tools( - AgentRunSpec( + make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -202,9 +204,10 @@ async def test_runner_does_not_batch_exclusive_read_only_tools(): tools.register(ddg_like) tools.register(read_b) - runner = AgentRunner(MagicMock()) + provider = MagicMock() + runner = AgentRunner() await runner._execute_tools( - AgentRunSpec( + make_run_spec(provider, initial_messages=[], tools=tools, model="test-model", @@ -260,8 +263,8 @@ async def test_runner_rejects_near_miss_tool_name_without_executing(): shared_events=shared_events, )) - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "read notes"}], tools=tools, model="test-model", @@ -379,7 +382,7 @@ async def test_runner_treats_legacy_entry_point_error_prefix_as_tool_error(tmp_p usage={}, )) - result = await AgentRunner(provider).run(AgentRunSpec( + result = await AgentRunner().run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "run plugin"}], tools=_load_entry_point_plugin(_LegacyErrorPluginTool, tmp_path), model="test-model", @@ -408,7 +411,7 @@ async def test_runner_preserves_structured_plugin_success_that_starts_with_error LLMResponse(content="done", tool_calls=[], usage={}), ]) - result = await AgentRunner(provider).run(AgentRunSpec( + result = await AgentRunner().run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "run plugin"}], tools=_load_entry_point_plugin(_StructuredSuccessPluginTool, tmp_path), model="test-model", @@ -449,8 +452,8 @@ async def test_runner_blocks_repeated_external_fetches(): tools.get_definitions.return_value = [] tools.execute = AsyncMock(return_value="page content") - runner = AgentRunner(provider) - result = await runner.run(AgentRunSpec( + runner = AgentRunner() + result = await runner.run(make_run_spec(provider, initial_messages=[{"role": "user", "content": "research task"}], tools=tools, model="test-model", diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py index 18723f5f..ede30d36 100644 --- a/tests/agent/test_runtime_refresh.py +++ b/tests/agent/test_runtime_refresh.py @@ -39,10 +39,10 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None: assert loop.provider is new_provider assert loop.model == "new-model" assert loop.context_window_tokens == 2000 - assert loop.runner.provider is new_provider + assert not hasattr(loop.runner, "provider") assert loop.subagents.provider is new_provider assert loop.subagents.model == "new-model" - assert loop.subagents.runner.provider is new_provider + assert not hasattr(loop.subagents.runner, "provider") assert loop.consolidator.provider is new_provider assert loop.consolidator.model == "new-model" assert loop.consolidator.context_window_tokens == 2000 @@ -71,7 +71,7 @@ def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None: assert runtime.provider is new_provider assert runtime.model == "new-model" assert loop.provider is new_provider - assert loop.runner.provider is new_provider + assert not hasattr(loop.runner, "provider") def test_settings_context_window_refreshes_runtime_state( diff --git a/tests/agent/test_self_model_preset.py b/tests/agent/test_self_model_preset.py index 4fa25af2..8ce87013 100644 --- a/tests/agent/test_self_model_preset.py +++ b/tests/agent/test_self_model_preset.py @@ -107,9 +107,9 @@ def test_model_preset_setter_replaces_provider_from_snapshot(tmp_path) -> None: loop.set_model_preset("deep") assert loop.provider is new_provider - assert loop.runner.provider is new_provider + assert not hasattr(loop.runner, "provider") assert loop.subagents.provider is new_provider - assert loop.subagents.runner.provider is new_provider + assert not hasattr(loop.subagents.runner, "provider") assert loop.consolidator.provider is new_provider assert loop.model == "anthropic/claude-opus-4-5" assert loop.context_window_tokens == 200_000 diff --git a/tests/agent/test_subagent_lifecycle.py b/tests/agent/test_subagent_lifecycle.py index d68b3f7c..c7c9e69f 100644 --- a/tests/agent/test_subagent_lifecycle.py +++ b/tests/agent/test_subagent_lifecycle.py @@ -88,7 +88,7 @@ class TestSetProvider: sm.set_provider(new_provider, "new-model") assert sm.provider is new_provider assert sm.model == "new-model" - assert sm.runner.provider is new_provider + assert not hasattr(sm.runner, "provider") # --------------------------------------------------------------------------- diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index bc656b9f..dc1b7e8f 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -576,7 +576,7 @@ async def test_run_model_override_is_per_run_and_restores_default(tmp_path): async def fake_process_direct(message, *, session_key, hooks): assert bot._loop.provider is override_provider - assert bot._loop.runner.provider is override_provider + assert not hasattr(bot._loop.runner, "provider") assert bot._loop.model == "openai/gpt-4.1-mini" assert bot._loop.context_window_tokens == 4096 return OutboundMessage(channel="cli", chat_id="direct", content="ok") @@ -591,7 +591,7 @@ async def test_run_model_override_is_per_run_and_restores_default(tmp_path): model_preset=None, ) assert bot._loop.provider is original_provider - assert bot._loop.runner.provider is original_provider + assert not hasattr(bot._loop.runner, "provider") assert bot._loop.model == original_model assert bot._loop._provider_signature == original_signature