refactor(agent): make runner consume required runtime

This commit is contained in:
chengyongru 2026-07-10 14:09:35 +08:00 committed by Xubin Ren
parent 3f8170e835
commit b4f069800e
21 changed files with 423 additions and 326 deletions

View File

@ -301,12 +301,13 @@ class AgentLoop:
# One file-read/write tracker per logical session. The tool registry is # One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars. # shared by this loop, so tools resolve the active state via contextvars.
self._file_state_store = FileStateStore() self._file_state_store = FileStateStore()
self.runner = AgentRunner(provider) self.runner = AgentRunner()
self.subagents = SubagentManager( self.subagents = SubagentManager(
provider=provider, provider=provider,
workspace=workspace, workspace=workspace,
bus=bus, bus=bus,
model=self.model, model=self.model,
context_window_tokens=self.context_window_tokens,
tools_config=_tc, tools_config=_tc,
max_tool_result_chars=self.max_tool_result_chars, max_tool_result_chars=self.max_tool_result_chars,
restrict_to_workspace=restrict_to_workspace, restrict_to_workspace=restrict_to_workspace,
@ -456,8 +457,7 @@ class AgentLoop:
self.provider = provider self.provider = provider
self.model = model self.model = model
self.context_window_tokens = context_window_tokens self.context_window_tokens = context_window_tokens
self.runner.provider = provider self.subagents.set_provider(provider, model, context_window_tokens)
self.subagents.set_provider(provider, model)
self.consolidator.set_provider(provider, model, context_window_tokens) self.consolidator.set_provider(provider, model, context_window_tokens)
self._sync_replay_max_messages() self._sync_replay_max_messages()
self._provider_signature = snapshot.signature self._provider_signature = snapshot.signature
@ -864,7 +864,7 @@ class AgentLoop:
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages, initial_messages=initial_messages,
tools=effective_tools, tools=effective_tools,
model=self.model, runtime=self.llm_runtime(),
max_iterations=self.max_iterations, max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars, max_tool_result_chars=self.max_tool_result_chars,
hook=hook, hook=hook,
@ -872,7 +872,6 @@ class AgentLoop:
concurrent_tools=True, concurrent_tools=True,
workspace=effective_scope.project_path, workspace=effective_scope.project_path,
session_key=session.key if session else None, session_key=session.key if session else None,
context_window_tokens=self.context_window_tokens,
context_block_limit=self.context_block_limit, context_block_limit=self.context_block_limit,
provider_retry_mode=self.provider_retry_mode, provider_retry_mode=self.provider_retry_mode,
progress_callback=on_progress, progress_callback=on_progress,

View File

@ -30,6 +30,7 @@ from nanobot.utils.helpers import (
strip_reasoning_tags, strip_reasoning_tags,
strip_think, strip_think,
) )
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import ( from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE, EMPTY_FINAL_RESPONSE_MESSAGE,
@ -61,12 +62,9 @@ class AgentRunSpec:
initial_messages: list[dict[str, Any]] initial_messages: list[dict[str, Any]]
tools: ToolRegistry tools: ToolRegistry
model: str runtime: LLMRuntime
max_iterations: int max_iterations: int
max_tool_result_chars: int max_tool_result_chars: int
temperature: float | None = None
max_tokens: int | None = None
reasoning_effort: str | None = None
hook: AgentHook | None = None hook: AgentHook | None = None
error_message: str | None = _DEFAULT_ERROR_MESSAGE error_message: str | None = _DEFAULT_ERROR_MESSAGE
max_iterations_message: str | None = None max_iterations_message: str | None = None
@ -74,7 +72,6 @@ class AgentRunSpec:
fail_on_tool_error: bool = False fail_on_tool_error: bool = False
workspace: Path | None = None workspace: Path | None = None
session_key: str | None = None session_key: str | None = None
context_window_tokens: int | None = None
context_block_limit: int | None = None context_block_limit: int | None = None
provider_retry_mode: str = "standard" provider_retry_mode: str = "standard"
progress_callback: Any | None = None progress_callback: Any | None = None
@ -105,8 +102,7 @@ class AgentRunResult:
class AgentRunner: class AgentRunner:
"""Run a tool-capable LLM loop without product-layer concerns.""" """Run a tool-capable LLM loop without product-layer concerns."""
def __init__(self, provider: LLMProvider): def __init__(self) -> None:
self.provider = provider
self.context_governor = ContextGovernor() self.context_governor = ContextGovernor()
@staticmethod @staticmethod
@ -189,7 +185,7 @@ class AgentRunner:
{ {
"phase": "final_response", "phase": "final_response",
"iteration": iteration, "iteration": iteration,
"model": spec.model, "model": spec.runtime.model,
"assistant_message": assistant_message, "assistant_message": assistant_message,
"completed_tool_results": [], "completed_tool_results": [],
"pending_tool_calls": [], "pending_tool_calls": [],
@ -344,15 +340,15 @@ class AgentRunner:
injection_cycles = 0 injection_cycles = 0
compacted_tool_call_ids: set[str] = set() compacted_tool_call_ids: set[str] = set()
governance_config = ContextGovernanceConfig( governance_config = ContextGovernanceConfig(
provider=self.provider, provider=spec.runtime.provider,
model=spec.model, model=spec.runtime.model,
tools=spec.tools, tools=spec.tools,
workspace=spec.workspace, workspace=spec.workspace,
session_key=spec.session_key, session_key=spec.session_key,
max_tool_result_chars=spec.max_tool_result_chars, 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, 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), inflight_start_index=len(spec.initial_messages),
) )
@ -429,7 +425,7 @@ class AgentRunner:
{ {
"phase": "awaiting_tools", "phase": "awaiting_tools",
"iteration": iteration, "iteration": iteration,
"model": spec.model, "model": spec.runtime.model,
"assistant_message": assistant_message, "assistant_message": assistant_message,
"completed_tool_results": [], "completed_tool_results": [],
"pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls], "pending_tool_calls": [tc.to_openai_tool_call() for tc in response.tool_calls],
@ -491,7 +487,7 @@ class AgentRunner:
{ {
"phase": "tools_completed", "phase": "tools_completed",
"iteration": iteration, "iteration": iteration,
"model": spec.model, "model": spec.runtime.model,
"assistant_message": assistant_message, "assistant_message": assistant_message,
"completed_tool_results": completed_tool_results, "completed_tool_results": completed_tool_results,
"pending_tool_calls": [], "pending_tool_calls": [],
@ -645,7 +641,7 @@ class AgentRunner:
{ {
"phase": "final_response", "phase": "final_response",
"iteration": iteration, "iteration": iteration,
"model": spec.model, "model": spec.runtime.model,
"assistant_message": messages[-1], "assistant_message": messages[-1],
"completed_tool_results": [], "completed_tool_results": [],
"pending_tool_calls": [], "pending_tool_calls": [],
@ -702,16 +698,14 @@ class AgentRunner:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"model": spec.model, "model": spec.runtime.model,
"retry_mode": spec.provider_retry_mode, "retry_mode": spec.provider_retry_mode,
"on_retry_wait": spec.retry_wait_callback, "on_retry_wait": spec.retry_wait_callback,
} }
if spec.temperature is not None: generation = spec.runtime.generation
kwargs["temperature"] = spec.temperature kwargs["temperature"] = generation.temperature
if spec.max_tokens is not None: kwargs["max_tokens"] = generation.max_tokens
kwargs["max_tokens"] = spec.max_tokens kwargs["reasoning_effort"] = generation.reasoning_effort
if spec.reasoning_effort is not None:
kwargs["reasoning_effort"] = spec.reasoning_effort
return kwargs return kwargs
async def _request_model( async def _request_model(
@ -746,7 +740,7 @@ class AgentRunner:
not wants_streaming not wants_streaming
and spec.stream_progress_deltas and spec.stream_progress_deltas
and spec.progress_callback is not None 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 progress_state: dict[str, bool] | None = None
@ -774,7 +768,7 @@ class AgentRunner:
async def _stream_recover() -> None: async def _stream_recover() -> None:
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
coro = self.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs, **kwargs,
on_content_delta=_stream, on_content_delta=_stream,
on_thinking_delta=_thinking, on_thinking_delta=_thinking,
@ -805,12 +799,12 @@ class AgentRunner:
context.streamed_content = True context.streamed_content = True
await spec.progress_callback(incremental) await spec.progress_callback(incremental)
coro = self.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs, **kwargs,
on_content_delta=_stream_progress, on_content_delta=_stream_progress,
) )
else: else:
coro = self.provider.chat_with_retry(**kwargs) coro = spec.runtime.provider.chat_with_retry(**kwargs)
# Streaming requests already have provider-level idle timeouts # Streaming requests already have provider-level idle timeouts
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock # (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
@ -985,7 +979,7 @@ class AgentRunner:
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
) -> LLMResponse: ) -> LLMResponse:
kwargs = self._build_request_kwargs(spec, messages, tools=None) 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 @staticmethod
def _budget_exhausted_finalization_messages( def _budget_exhausted_finalization_messages(
@ -1033,7 +1027,12 @@ class AgentRunner:
tools = spec.tools.get_definitions() tools = spec.tools.get_definitions()
except Exception: except Exception:
tools = None 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( assistant_message = build_assistant_message(
response.content or "", response.content or "",
tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls], tool_calls=[tc.to_openai_tool_call() for tc in response.tool_calls],

View File

@ -26,6 +26,7 @@ from nanobot.security.workspace_access import (
reset_workspace_scope, reset_workspace_scope,
workspace_sandbox_status, workspace_sandbox_status,
) )
from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
@ -81,6 +82,7 @@ class SubagentManager:
bus: MessageBus, bus: MessageBus,
max_tool_result_chars: int, max_tool_result_chars: int,
model: str | None = None, model: str | None = None,
context_window_tokens: int | None = None,
tools_config: ToolsConfig | None = None, tools_config: ToolsConfig | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None, disabled_skills: list[str] | None = None,
@ -94,6 +96,7 @@ class SubagentManager:
self.workspace = workspace self.workspace = workspace
self.bus = bus self.bus = bus
self.model = model or provider.get_default_model() 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.tools_config = tools_config or ToolsConfig()
self.max_tool_result_chars = max_tool_result_chars self.max_tool_result_chars = max_tool_result_chars
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
@ -113,7 +116,7 @@ class SubagentManager:
if fail_on_tool_error is not None if fail_on_tool_error is not None
else defaults.fail_on_tool_error 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._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {} self._task_statuses: dict[str, SubagentStatus] = {}
@ -149,10 +152,16 @@ class SubagentManager:
ToolLoader().load(ctx, registry, scope="subagent") ToolLoader().load(ctx, registry, scope="subagent")
return registry 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.provider = provider
self.model = model self.model = model
self.runner.provider = provider if context_window_tokens is not None:
self.context_window_tokens = context_window_tokens
async def spawn( async def spawn(
self, self,
@ -246,11 +255,15 @@ class SubagentManager:
) )
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
try: 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( result = await self.runner.run(AgentRunSpec(
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model=self.model, runtime=runtime,
temperature=temperature,
max_iterations=self.max_iterations, max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars, max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status), hook=_SubagentHook(task_id, status),

View File

@ -38,8 +38,8 @@ class LLMRuntime:
snapshot_signature: tuple[object, ...] | None = None, snapshot_signature: tuple[object, ...] | None = None,
) -> LLMRuntime: ) -> LLMRuntime:
"""Capture provider defaults without retaining mutable generation state.""" """Capture provider defaults without retaining mutable generation state."""
generation = provider.generation
defaults = GenerationSettings() defaults = GenerationSettings()
generation = getattr(provider, "generation", defaults)
return cls( return cls(
provider=provider, provider=provider,
model=model, model=model,

View File

@ -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)

View File

@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@ -17,7 +18,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_preserves_reasoning_fields_and_tool_results(): 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) provider = MagicMock(spec=LLMProvider)
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "do task"}, {"role": "user", "content": "do task"},
@ -74,7 +75,7 @@ async def test_runner_preserves_reasoning_fields_and_tool_results():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_returns_max_iterations_fallback(): 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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -108,7 +109,7 @@ async def test_runner_returns_max_iterations_fallback():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_uses_no_tools_finalization_after_max_iterations(): 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) provider = MagicMock(spec=LLMProvider)
calls: list[dict] = [] calls: list[dict] = []
@ -137,8 +138,8 @@ async def test_runner_uses_no_tools_finalization_after_max_iterations():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "inspect the repo"}], initial_messages=[{"role": "user", "content": "inspect the repo"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -160,7 +161,7 @@ async def test_runner_uses_no_tools_finalization_after_max_iterations():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_times_out_hung_llm_request(): 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) provider = MagicMock(spec=LLMProvider)
@ -171,9 +172,9 @@ async def test_runner_times_out_hung_llm_request():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
started = time.monotonic() started = time.monotonic()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -190,7 +191,7 @@ async def test_runner_times_out_hung_llm_request():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests(): async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests():
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
streamed: list[str] = [] 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: async def on_stream(self, context: AgentHookContext, delta: str) -> None:
streamed.append(delta) streamed.append(delta)
runner = AgentRunner(provider) runner = AgentRunner()
wait_for = AsyncMock(side_effect=AssertionError("streaming path must not use wait_for")) wait_for = AsyncMock(side_effect=AssertionError("streaming path must not use wait_for"))
with patch("nanobot.agent.runner.asyncio.wait_for", 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"}], initial_messages=[{"role": "user", "content": "think for a while"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -236,7 +237,7 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_replaces_empty_tool_result_with_marker(): 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) provider = MagicMock(spec=LLMProvider)
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="") tools.execute = AsyncMock(return_value="")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -275,7 +276,7 @@ async def test_runner_replaces_empty_tool_result_with_marker():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_retries_empty_final_response_with_summary_prompt(): async def test_runner_retries_empty_final_response_with_summary_prompt():
"""Empty responses get 2 silent retries before finalization kicks in.""" """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) provider = MagicMock(spec=LLMProvider)
calls: list[dict] = [] calls: list[dict] = []
@ -298,8 +299,8 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -320,7 +321,7 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_uses_specific_message_after_empty_finalization_retry(): async def test_runner_uses_specific_message_after_empty_finalization_retry():
"""After silent retries + finalization all return empty, stop_reason is empty_final_response.""" """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 from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
@ -332,8 +333,8 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", 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. Sequence: tool_call -> empty -> tool_call -> final text.
The runner should recover via silent retry and complete normally. 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) provider = MagicMock(spec=LLMProvider)
call_count = 0 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.get_definitions.return_value = [{"type": "function", "function": {"name": "read_file"}}]
tool_registry.execute = AsyncMock(side_effect=fake_tool) tool_registry.execute = AsyncMock(side_effect=fake_tool)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "read both files"}], initial_messages=[{"role": "user", "content": "read both files"}],
tools=tool_registry, tools=tool_registry,
model="test-model", 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(): async def test_runner_accumulates_usage_and_preserves_cached_tokens():
"""Runner should accumulate prompt/completion tokens across iterations """Runner should accumulate prompt/completion tokens across iterations
and preserve cached_tokens from provider responses.""" and preserve cached_tokens from provider responses."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
call_count = {"n": 0} call_count = {"n": 0}
@ -433,8 +434,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="file content") tools.execute = AsyncMock(return_value="file content")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", 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" internal retry diagnostics like "Model request failed, retry in 1s"
to leak to end-user channels as normal progress updates. 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 = {} captured: dict = {}
@ -472,8 +473,8 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
progress_cb = AsyncMock() progress_cb = AsyncMock()
retry_wait_cb = AsyncMock() retry_wait_cb = AsyncMock()
runner = AgentRunner(provider) runner = AgentRunner()
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "hi"}, {"role": "user", "content": "hi"},
@ -498,7 +499,7 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_passes_temperature_to_provider(): async def test_runner_passes_temperature_to_provider():
"""temperature from AgentRunSpec should reach provider.chat_with_retry.""" """temperature from AgentRunSpec should reach provider.chat_with_retry."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
captured: dict = {} captured: dict = {}
@ -511,8 +512,8 @@ async def test_runner_passes_temperature_to_provider():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -527,7 +528,7 @@ async def test_runner_passes_temperature_to_provider():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_passes_max_tokens_to_provider(): async def test_runner_passes_max_tokens_to_provider():
"""max_tokens from AgentRunSpec should reach provider.chat_with_retry.""" """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 = {} captured: dict = {}
@ -540,8 +541,8 @@ async def test_runner_passes_max_tokens_to_provider():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -556,7 +557,7 @@ async def test_runner_passes_max_tokens_to_provider():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_passes_reasoning_effort_to_provider(): async def test_runner_passes_reasoning_effort_to_provider():
"""reasoning_effort from AgentRunSpec should reach provider.chat_with_retry.""" """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 = {} captured: dict = {}
@ -569,8 +570,8 @@ async def test_runner_passes_reasoning_effort_to_provider():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@ -15,7 +16,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_returns_structured_tool_error(): 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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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.get_definitions.return_value = []
tools.execute = AsyncMock(side_effect=RuntimeError("boom")) 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=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", 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 """When LLM returns finish_reason='error', the error content must NOT be
appended to the messages list (prevents polluting session history).""" appended to the messages list (prevents polluting session history)."""
from nanobot.agent.runner import ( from nanobot.agent.runner import (
AgentRunSpec,
AgentRunner,
_PERSISTED_MODEL_ERROR_PLACEHOLDER, _PERSISTED_MODEL_ERROR_PLACEHOLDER,
AgentRunner,
) )
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
@ -61,8 +61,8 @@ async def test_llm_error_not_appended_to_session_messages():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -81,7 +81,7 @@ async def test_llm_error_not_appended_to_session_messages():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_llm_arrearage_error_surfaces_clear_message(): async def test_llm_arrearage_error_surfaces_clear_message():
"""Arrearage errors yield a clear user-facing message, not a raw dump (#3006).""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
@ -90,8 +90,8 @@ async def test_llm_arrearage_error_surfaces_clear_message():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -117,7 +117,7 @@ async def test_runner_ignores_tool_calls_when_finish_reason_blocks_execution(
expected_stop_reason: str, expected_stop_reason: str,
): ):
"""Provider/gateway-injected tool calls under terminal block reasons must not run.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="should not run") 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"}], initial_messages=[{"role": "user", "content": "run a command"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -147,7 +147,7 @@ async def test_runner_ignores_tool_calls_when_finish_reason_blocks_execution(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_tool_error_sets_final_content(): 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) provider = MagicMock(spec=LLMProvider)
@ -163,8 +163,8 @@ async def test_runner_tool_error_sets_final_content():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(side_effect=RuntimeError("boom")) 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=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -179,7 +179,7 @@ async def test_runner_tool_error_sets_final_content():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_preserves_successful_exec_output_that_starts_with_error(): 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) 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value=output) tools.execute = AsyncMock(return_value=output)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "run report"}], initial_messages=[{"role": "user", "content": "run report"}],
tools=tools, tools=tools,
model="test-model", 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(): async def test_runner_tool_error_preserves_tool_results_in_messages():
"""When a tool raises a fatal error, its results must still be appended """When a tool raises a fatal error, its results must still be appended
to messages so the session never contains orphan tool_calls (#2943).""" 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) 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.get_definitions.return_value = []
tools.execute = AsyncMock(side_effect=fake_execute) tools.execute = AsyncMock(side_effect=fake_execute)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do stuff"}], initial_messages=[{"role": "user", "content": "do stuff"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.base import LLMProvider, LLMResponse
@ -20,7 +21,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_exits_normally_without_predicate(): async def test_runner_exits_normally_without_predicate():
"""Baseline: no predicate, runner exits with completed on final text.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
@ -29,8 +30,8 @@ async def test_runner_exits_normally_without_predicate():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -45,7 +46,7 @@ async def test_runner_exits_normally_without_predicate():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_exits_normally_with_inactive_goal(): async def test_runner_exits_normally_with_inactive_goal():
"""Predicate returns False, runner should exit normally.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
@ -54,8 +55,8 @@ async def test_runner_exits_normally_with_inactive_goal():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", 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 "completed". With the fix the runner is forced to continue until
max_iterations is hit. max_iterations is hit.
""" """
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
@ -86,8 +87,8 @@ async def test_runner_forces_continue_when_goal_active():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -107,7 +108,7 @@ async def test_runner_forces_continue_when_goal_active():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_respects_max_iterations_even_with_active_goal(): async def test_runner_respects_max_iterations_even_with_active_goal():
"""A single iteration with active goal still hits max_iterations.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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 = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -132,7 +133,7 @@ async def test_runner_respects_max_iterations_even_with_active_goal():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_goal_continue_not_limited_by_injection_cycle_cap(): async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
"""Synthetic goal continuation should be governed by max_iterations.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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 = [] tools.get_definitions.return_value = []
max_iterations = _MAX_INJECTION_CYCLES + 3 max_iterations = _MAX_INJECTION_CYCLES + 3
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -160,7 +161,7 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_does_not_force_continue_on_error(): async def test_runner_does_not_force_continue_on_error():
"""Even with active goal, an LLM error should exit with stop_reason="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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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 = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -186,7 +187,7 @@ async def test_runner_does_not_force_continue_on_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_uses_custom_goal_continue_message(): async def test_runner_uses_custom_goal_continue_message():
"""Custom goal_continue_message should be injected instead of the default.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( 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" custom_msg = "CUSTOM_CONTINUE_PLEASE"
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -215,7 +216,7 @@ async def test_runner_uses_custom_goal_continue_message():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_resolves_goal_continue_message_lazily(): async def test_runner_resolves_goal_continue_message_lazily():
"""The continuation text can depend on goal metadata created during the run.""" """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 = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse( provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
@ -229,8 +230,8 @@ async def test_runner_resolves_goal_continue_message_lazily():
calls["n"] += 1 calls["n"] += 1
return "Goal (active):\nWrite the article draft." return "Goal (active):\nWrite the article draft."
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.context_governance import ( from nanobot.agent.context_governance import (
BACKFILL_CONTENT, BACKFILL_CONTENT,
MICROCOMPACT_KEEP_RECENT, MICROCOMPACT_KEEP_RECENT,
@ -29,14 +30,14 @@ def _governance_config(
) -> ContextGovernanceConfig: ) -> ContextGovernanceConfig:
return ContextGovernanceConfig( return ContextGovernanceConfig(
provider=provider, provider=provider,
model=spec.model, model=spec.runtime.model,
tools=tools, tools=tools,
workspace=spec.workspace, workspace=spec.workspace,
session_key=spec.session_key, session_key=spec.session_key,
max_tool_result_chars=spec.max_tool_result_chars, 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, 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, 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"}, {"role": "user", "content": "hello"},
] ]
runner = AgentRunner(provider) runner = AgentRunner()
runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign] runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
side_effect=RuntimeError("boom") side_effect=RuntimeError("boom")
) )
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=initial_messages, initial_messages=initial_messages,
tools=tools, tools=tools,
model="test-model", 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": "tool", "tool_call_id": "call_1", "content": "tool output"},
{"role": "assistant", "content": "after tool"}, {"role": "assistant", "content": "after tool"},
] ]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -153,7 +154,7 @@ def test_snip_history_reserves_budget_for_tool_definitions(monkeypatch):
{"role": "assistant", "content": "recent answer"}, {"role": "assistant", "content": "recent answer"},
{"role": "user", "content": "recent two"}, {"role": "user", "content": "recent two"},
] ]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -281,8 +282,8 @@ async def test_runner_drops_orphan_tool_results_before_model_request():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "old user"}, {"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"}, {"role": "user", "content": "new prompt"},
] ]
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=initial_messages, initial_messages=initial_messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -503,7 +504,7 @@ def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch):
total = MICROCOMPACT_KEEP_RECENT + 5 total = MICROCOMPACT_KEEP_RECENT + 5
long_content = "x" * 600 long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -537,7 +538,7 @@ def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch):
total = MICROCOMPACT_KEEP_RECENT + 8 total = MICROCOMPACT_KEEP_RECENT + 8
long_content = "x" * 600 long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -581,7 +582,7 @@ def test_microcompact_compacts_newest_when_it_alone_overflows(monkeypatch):
long_content = "x" * 600 long_content = "x" * 600
messages = _microcompact_messages(total=1, tool_name="read_file", content=long_content) messages = _microcompact_messages(total=1, tool_name="read_file", content=long_content)
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -622,7 +623,7 @@ def test_context_governor_keeps_compaction_boundary_stable(monkeypatch):
total = MICROCOMPACT_KEEP_RECENT + 8 total = MICROCOMPACT_KEEP_RECENT + 8
long_content = "x" * 600 long_content = "x" * 600
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content) messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -662,7 +663,7 @@ def test_microcompact_preserves_short_results(monkeypatch):
total = MICROCOMPACT_KEEP_RECENT + 5 total = MICROCOMPACT_KEEP_RECENT + 5
messages = _microcompact_messages(total=total, tool_name="exec", content="short") messages = _microcompact_messages(total=total, tool_name="exec", content="short")
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -695,7 +696,7 @@ def test_microcompact_skips_non_compactable_tools(monkeypatch):
total = MICROCOMPACT_KEEP_RECENT + 5 total = MICROCOMPACT_KEEP_RECENT + 5
long_content = "y" * 1000 long_content = "y" * 1000
messages = _microcompact_messages(total=total, tool_name="message", content=long_content) messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", 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"}, {"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"},
] ]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", 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"}, {"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
] ]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=messages, initial_messages=messages,
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@ -16,7 +17,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_calls_hooks_in_order(): async def test_runner_calls_hooks_in_order():
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
call_count = {"n": 0} call_count = {"n": 0}
@ -67,8 +68,8 @@ async def test_runner_calls_hooks_in_order():
events.append(("finalize_content", context.iteration, content)) events.append(("finalize_content", context.iteration, content))
return content.upper() if content else content return content.upper() if content else content
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -100,7 +101,7 @@ async def test_runner_calls_hooks_in_order():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_streaming_hook_receives_deltas_and_end_signal(): async def test_runner_streaming_hook_receives_deltas_and_end_signal():
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
streamed: list[str] = [] 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: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
endings.append(resuming) endings.append(resuming)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", 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(): async def test_runner_passes_cached_tokens_to_hook_context():
"""Hook context.usage should contain cached_tokens.""" """Hook context.usage should contain cached_tokens."""
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
captured_usage: list[dict] = [] captured_usage: list[dict] = []
@ -166,8 +167,8 @@ async def test_runner_passes_cached_tokens_to_hook_context():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -184,7 +185,7 @@ async def test_runner_passes_cached_tokens_to_hook_context():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch): async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch):
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
captured_usage: list[dict] = [] 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) monkeypatch.setattr("nanobot.agent.runner.estimate_message_tokens", lambda message: 7)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -225,7 +226,7 @@ async def test_runner_estimates_usage_when_provider_omits_usage(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_calls_run_level_hooks_on_success(): async def test_runner_calls_run_level_hooks_on_success():
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
events: list[tuple] = [] 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: async def on_finally(self, context: AgentRunHookContext) -> None:
events.append(("on_finally", context.stop_reason, context.exception)) events.append(("on_finally", context.stop_reason, context.exception))
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -297,7 +298,7 @@ async def test_runner_calls_run_level_hooks_on_success():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_run_level_context_is_detached_snapshot(): async def test_runner_run_level_context_is_detached_snapshot():
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
call_count = {"n": 0} 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: async def on_finally(self, context: AgentRunHookContext) -> None:
context.messages[0]["content"] = "mutated-finally" context.messages[0]["content"] = "mutated-finally"
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -351,7 +352,7 @@ async def test_runner_run_level_context_is_detached_snapshot():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_calls_on_error_for_model_error_result(): async def test_runner_calls_on_error_for_model_error_result():
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
events: list[tuple] = [] 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: async def on_finally(self, context: AgentRunHookContext) -> None:
events.append(("on_finally", context.stop_reason, context.error)) events.append(("on_finally", context.stop_reason, context.error))
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -399,7 +400,7 @@ async def test_runner_calls_on_error_for_model_error_result():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_calls_on_error_and_finally_for_unhandled_exception(): async def test_runner_calls_on_error_and_finally_for_unhandled_exception():
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
events: list[tuple] = [] 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: async def on_finally(self, context: AgentRunHookContext) -> None:
events.append(("on_finally", context.stop_reason)) events.append(("on_finally", context.stop_reason))
runner = AgentRunner(provider) runner = AgentRunner()
with pytest.raises(RuntimeError, match="provider exploded"): with pytest.raises(RuntimeError, match="provider exploded"):
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -450,7 +451,7 @@ async def test_runner_calls_on_error_and_finally_for_unhandled_exception():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_preserves_original_exception_when_finally_hook_fails(): async def test_runner_preserves_original_exception_when_finally_hook_fails():
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) 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: async def on_finally(self, context: AgentRunHookContext) -> None:
raise RuntimeError("finally exploded") raise RuntimeError("finally exploded")
runner = AgentRunner(provider) runner = AgentRunner()
with pytest.raises(RuntimeError, match="provider exploded"): with pytest.raises(RuntimeError, match="provider exploded"):
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -482,7 +483,7 @@ async def test_runner_does_not_report_cancellation_as_error():
import asyncio import asyncio
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) provider = MagicMock(spec=LLMProvider)
events: list[tuple] = [] 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, type(context.exception).__name__ if context.exception else None,
)) ))
runner = AgentRunner(provider) runner = AgentRunner()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -534,7 +535,7 @@ async def test_runner_preserves_cancellation_when_finally_hook_fails():
import asyncio import asyncio
from nanobot.agent.hook import AgentHook, AgentRunHookContext from nanobot.agent.hook import AgentHook, AgentRunHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider) 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: async def on_finally(self, context: AgentRunHookContext) -> None:
raise RuntimeError("finally exploded") raise RuntimeError("finally exploded")
runner = AgentRunner(provider) runner = AgentRunner()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await runner.run(AgentRunSpec( await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@ -42,13 +43,13 @@ def _make_loop(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_returns_empty_when_no_callback(): async def test_drain_injections_returns_empty_when_no_callback():
"""No injection_callback → empty list.""" """No injection_callback → empty list."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=None, injection_callback=None,
@ -60,11 +61,11 @@ async def test_drain_injections_returns_empty_when_no_callback():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_extracts_content_from_inbound_messages(): async def test_drain_injections_extracts_content_from_inbound_messages():
"""Should extract .content from InboundMessage objects.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
@ -76,7 +77,7 @@ async def test_drain_injections_extracts_content_from_inbound_messages():
async def cb(): async def cb():
return msgs return msgs
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, injection_callback=cb,
@ -91,11 +92,11 @@ async def test_drain_injections_extracts_content_from_inbound_messages():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_passes_limit_to_callback_when_supported(): async def test_drain_injections_passes_limit_to_callback_when_supported():
"""Limit-aware callbacks can preserve overflow in their own queue.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
seen_limits: list[int] = [] seen_limits: list[int] = []
@ -109,7 +110,7 @@ async def test_drain_injections_passes_limit_to_callback_when_supported():
seen_limits.append(limit) seen_limits.append(limit)
return msgs[:limit] return msgs[:limit]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, injection_callback=cb,
@ -126,11 +127,11 @@ async def test_drain_injections_passes_limit_to_callback_when_supported():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_skips_empty_content(): async def test_drain_injections_skips_empty_content():
"""Messages with blank content should be filtered out.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
@ -143,7 +144,7 @@ async def test_drain_injections_skips_empty_content():
async def cb(): async def cb():
return msgs return msgs
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, injection_callback=cb,
@ -155,10 +156,10 @@ async def test_drain_injections_skips_empty_content():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_filters_empty_dict_payloads(): async def test_drain_injections_filters_empty_dict_payloads():
"""Pre-normalized dict injections should obey the same empty-content guard.""" """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() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
@ -176,7 +177,7 @@ async def test_drain_injections_filters_empty_dict_payloads():
async def cb(): async def cb():
return msgs return msgs
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, 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.""" """Objects exposing content=None should be skipped rather than stringified."""
from types import SimpleNamespace from types import SimpleNamespace
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
@ -207,7 +208,7 @@ async def test_drain_injections_skips_objects_with_none_content():
SimpleNamespace(content="valid"), SimpleNamespace(content="valid"),
] ]
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, injection_callback=cb,
@ -219,17 +220,17 @@ async def test_drain_injections_skips_objects_with_none_content():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_handles_callback_exception(): async def test_drain_injections_handles_callback_exception():
"""If the callback raises, return empty list (error is logged).""" """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() provider = MagicMock()
runner = AgentRunner(provider) runner = AgentRunner()
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
async def cb(): async def cb():
raise RuntimeError("boom") raise RuntimeError("boom")
spec = AgentRunSpec( spec = make_run_spec(provider,
initial_messages=[], tools=tools, model="m", initial_messages=[], tools=tools, model="m",
max_iterations=1, max_tool_result_chars=1000, max_iterations=1, max_tool_result_chars=1000,
injection_callback=cb, injection_callback=cb,
@ -241,7 +242,7 @@ async def test_drain_injections_handles_callback_exception():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_checkpoint1_injects_after_tool_execution(): async def test_checkpoint1_injects_after_tool_execution():
"""Follow-up messages are injected after tool execution, before next LLM call.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", 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(): async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
"""After final response, if injections exist, stream_end should get resuming=True.""" """After final response, if injections exist, stream_end should get resuming=True."""
from nanobot.agent.hook import AgentHook, AgentHookContext 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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="quick follow-up")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -353,7 +354,7 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_checkpoint2_preserves_final_response_in_history_before_followup(): 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.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up question")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -530,7 +531,7 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_merges_multiple_injected_user_messages_without_losing_media(): async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
"""Multiple injected follow-ups should not create lossy consecutive user messages.""" """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() provider = MagicMock()
call_count = {"n": 0} call_count = {"n": 0}
@ -561,8 +562,8 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
] ]
return [] return []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -593,7 +594,7 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injection_cycles_capped_at_max(): async def test_injection_cycles_capped_at_max():
"""Injection cycles should be capped at _MAX_INJECTION_CYCLES.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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 [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
return [] return []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "start"}], initial_messages=[{"role": "user", "content": "start"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -634,7 +635,7 @@ async def test_injection_cycles_capped_at_max():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_injections_flag_is_false_by_default(): async def test_no_injections_flag_is_false_by_default():
"""had_injections should be False when no injection callback or no messages.""" """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() provider = MagicMock()
@ -645,8 +646,8 @@ async def test_no_injections_flag_is_false_by_default():
tools = MagicMock() tools = MagicMock()
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hi"}], initial_messages=[{"role": "user", "content": "hi"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -1089,7 +1090,7 @@ async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_on_fatal_tool_error(): async def test_drain_injections_on_fatal_tool_error():
"""Pending injections should be drained even when a fatal tool error occurs.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after error")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -1142,7 +1143,7 @@ async def test_drain_injections_on_fatal_tool_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_on_llm_error(): async def test_drain_injections_on_llm_error():
"""Pending injections should be drained when the LLM returns an error finish_reason.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after LLM error")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous response"}, {"role": "assistant", "content": "previous response"},
@ -1197,7 +1198,7 @@ async def test_drain_injections_on_llm_error():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_drain_injections_on_empty_final_response(): async def test_drain_injections_on_empty_final_response():
"""Pending injections should be drained when the runner exits due to empty 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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after empty")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous response"}, {"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. 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. 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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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") InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up after max iters")
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", 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(): 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.""" """Late follow-ups drained in max_iterations should still flip had_injections."""
from nanobot.agent.hook import AgentHook 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 from nanobot.bus.events import InboundMessage
provider = MagicMock() provider = MagicMock()
@ -1342,8 +1343,8 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat
) )
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "hello"}], initial_messages=[{"role": "user", "content": "hello"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -1366,7 +1367,7 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_injection_cycle_cap_on_error_path(): async def test_injection_cycle_cap_on_error_path():
"""Injection cycles should be capped even when every iteration hits an LLM error.""" """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 from nanobot.bus.events import InboundMessage
provider = MagicMock() 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 [InboundMessage(channel="cli", sender_id="u", chat_id="c", content=f"msg-{drain_count['n']}")]
return [] return []
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
{"role": "assistant", "content": "previous"}, {"role": "assistant", "content": "previous"},

View File

@ -6,13 +6,14 @@ import os
import time import time
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path): 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() provider = MagicMock()
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="x" * 20_000) tools.execute = AsyncMock(return_value="x" * 20_000)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do task"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", 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): async def test_read_file_result_is_not_offloaded(tmp_path):
"""read_file must not trigger generic offloading (prevents persist->read->persist loops).""" """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() provider = MagicMock()
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="x" * 20_000) tools.execute = AsyncMock(return_value="x" * 20_000)
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "read big file"}], initial_messages=[{"role": "user", "content": "read big file"}],
tools=tools, tools=tools,
model="test-model", 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(): 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() provider = MagicMock()
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner()
with patch( with patch(
"nanobot.agent.context_governance.maybe_persist_tool_result", "nanobot.agent.context_governance.maybe_persist_tool_result",
side_effect=RuntimeError("disk full"), 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"}], initial_messages=[{"role": "user", "content": "do task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -5,8 +5,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.hooks import FileEditActivityHook 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.agent.tools.filesystem import EditFileTool, WriteFileTool
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest 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 = [] tools.get_definitions.return_value = []
progress_cb = AsyncMock() progress_cb = AsyncMock()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "hi"}, {"role": "user", "content": "hi"},
@ -64,8 +65,8 @@ async def test_runner_streams_provider_progress_deltas_by_default():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
progress_cb = AsyncMock() progress_cb = AsyncMock()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "hi"}, {"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() provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "write a large file"}], initial_messages=[{"role": "user", "content": "write a large file"}],
tools=tools, tools=tools,
model="test-model", 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() provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "edit a file"}], initial_messages=[{"role": "user", "content": "edit a file"}],
tools=tools, tools=tools,
model="test-model", 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() provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "write a file"}], initial_messages=[{"role": "user", "content": "write a file"}],
tools=tools, tools=tools,
model="test-model", 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() provider.chat_with_retry = AsyncMock()
tools = Tools() tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner()
task = asyncio.create_task(runner.run(AgentRunSpec( task = asyncio.create_task(runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "write a file"}], initial_messages=[{"role": "user", "content": "write a file"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@ -38,7 +39,7 @@ class _RecordingHook(AgentHook):
async def test_runner_preserves_reasoning_fields_in_assistant_history(): async def test_runner_preserves_reasoning_fields_in_assistant_history():
"""Reasoning fields ride along on the persisted assistant message so """Reasoning fields ride along on the persisted assistant message so
follow-up provider calls retain the model's prior thinking context.""" 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() provider = MagicMock()
captured_second_call: list[dict] = [] 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.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result") tools.execute = AsyncMock(return_value="tool result")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[ initial_messages=[
{"role": "system", "content": "system"}, {"role": "system", "content": "system"},
{"role": "user", "content": "do task"}, {"role": "user", "content": "do task"},
@ -86,7 +87,7 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_emits_anthropic_thinking_blocks(): async def test_runner_emits_anthropic_thinking_blocks():
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
@ -106,8 +107,8 @@ async def test_runner_emits_anthropic_thinking_blocks():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
tools=tools, tools=tools,
model="test-model", 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(): async def test_runner_emits_inline_think_content_as_reasoning():
"""Models embedding reasoning in <think>...</think> blocks should have """Models embedding reasoning in <think>...</think> blocks should have
that content extracted and emitted, and stripped from the answer.""" 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() provider = MagicMock()
@ -142,8 +143,8 @@ async def test_runner_emits_inline_think_content_as_reasoning():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "what is the answer?"}], initial_messages=[{"role": "user", "content": "what is the answer?"}],
tools=tools, tools=tools,
model="test-model", 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(): async def test_runner_prefers_reasoning_content_over_inline_think():
"""Fallback priority: dedicated reasoning_content wins; inline <think> """Fallback priority: dedicated reasoning_content wins; inline <think>
is still scrubbed from the answer content.""" is still scrubbed from the answer content."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
@ -178,8 +179,8 @@ async def test_runner_prefers_reasoning_content_over_inline_think():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
tools=tools, tools=tools,
model="test-model", 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 """`reasoning_content` arrives only on the final response; streaming the
answer must not suppress it (the answer stream and the reasoning channel answer must not suppress it (the answer stream and the reasoning channel
are independent only the reasoning-already-emitted bit matters).""" 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 = MagicMock()
provider.supports_progress_deltas = True 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) progress_calls.append(content)
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
tools=tools, tools=tools,
model="test-model", 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(): async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
"""Inline `<think>` blocks streamed incrementally during the answer """Inline `<think>` blocks streamed incrementally during the answer
stream must not be re-emitted from the final response.""" 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 = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
@ -267,8 +268,8 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
pass pass
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "question"}], initial_messages=[{"role": "user", "content": "question"}],
tools=tools, tools=tools,
model="test-model", 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 """A non-streaming response carrying ``reasoning_content`` must emit
both a reasoning delta and an end marker so channels can finalize the both a reasoning delta and an end marker so channels can finalize the
in-place bubble.""" in-place bubble."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner
provider = MagicMock() provider = MagicMock()
@ -306,8 +307,8 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _RecordingHook() hook = _RecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "q"}], initial_messages=[{"role": "user", "content": "q"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -333,7 +334,7 @@ class _StreamRecordingHook(_RecordingHook):
async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup(): async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
"""Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``; """Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``;
final ``thinking_blocks`` must not emit again when already streamed.""" 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() provider = MagicMock()
@ -357,8 +358,8 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _StreamRecordingHook() hook = _StreamRecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "q"}], initial_messages=[{"role": "user", "content": "q"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -373,7 +374,7 @@ async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_strips_thinking_tags_from_native_thinking_deltas(): 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() provider = MagicMock()
@ -393,8 +394,8 @@ async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _StreamRecordingHook() hook = _StreamRecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "q"}], initial_messages=[{"role": "user", "content": "q"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -409,7 +410,7 @@ async def test_runner_strips_thinking_tags_from_native_thinking_deltas():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_ignores_empty_thinking_marker_before_final_reasoning(): 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() provider = MagicMock()
@ -432,8 +433,8 @@ async def test_runner_ignores_empty_thinking_marker_before_final_reasoning():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
hook = _StreamRecordingHook() hook = _StreamRecordingHook()
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "q"}], initial_messages=[{"role": "user", "content": "q"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -4,25 +4,43 @@ import pytest
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.config.schema import AgentDefaults 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.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: async def test_active_run_keeps_provider_captured_at_admission() -> None:
first_provider = MagicMock(spec=LLMProvider) first_provider = MagicMock(spec=LLMProvider)
second_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 first_calls = 0
second_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): async def first_chat(**kwargs):
nonlocal first_calls nonlocal first_calls, selected_runtime
first_calls += 1 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( return LLMResponse(
content="working", content="working",
tool_calls=[ToolCallRequest(id="call-1", name="read_file", arguments={})], 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( await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "read it"}], initial_messages=[{"role": "user", "content": "read it"}],
tools=tools, tools=tools,
model="captured-model", runtime=selected_runtime,
max_iterations=2, max_iterations=2,
max_tool_result_chars=AgentDefaults().max_tool_result_chars, max_tool_result_chars=AgentDefaults().max_tool_result_chars,
)) ))
assert first_calls == 2 assert first_calls == 2
assert second_calls == 0 assert second_calls == 0
assert request_temperatures == [0.2, 0.2]
assert selected_runtime.provider is second_provider

View File

@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest 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.agent.tools import ToolResult
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest 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=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", 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)" "Error: Command blocked by safety guard (internal/private URL detected)"
)) ))
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -162,8 +163,8 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
) )
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -214,8 +215,8 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
) )
) )
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -7,7 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest 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.base import Tool, ToolResult
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
@ -117,7 +118,7 @@ async def _run_optional_tool_response(response: LLMResponse):
shared_events=shared_events, 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"}], initial_messages=[{"role": "user", "content": "try optional"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -159,9 +160,10 @@ async def test_runner_batches_read_only_tools_before_exclusive_work():
tools.register(read_b) tools.register(read_b)
tools.register(write_a) tools.register(write_a)
runner = AgentRunner(MagicMock()) provider = MagicMock()
runner = AgentRunner()
await runner._execute_tools( await runner._execute_tools(
AgentRunSpec( make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -202,9 +204,10 @@ async def test_runner_does_not_batch_exclusive_read_only_tools():
tools.register(ddg_like) tools.register(ddg_like)
tools.register(read_b) tools.register(read_b)
runner = AgentRunner(MagicMock()) provider = MagicMock()
runner = AgentRunner()
await runner._execute_tools( await runner._execute_tools(
AgentRunSpec( make_run_spec(provider,
initial_messages=[], initial_messages=[],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -260,8 +263,8 @@ async def test_runner_rejects_near_miss_tool_name_without_executing():
shared_events=shared_events, shared_events=shared_events,
)) ))
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "read notes"}], initial_messages=[{"role": "user", "content": "read notes"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
@ -379,7 +382,7 @@ async def test_runner_treats_legacy_entry_point_error_prefix_as_tool_error(tmp_p
usage={}, usage={},
)) ))
result = await AgentRunner(provider).run(AgentRunSpec( result = await AgentRunner().run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "run plugin"}], initial_messages=[{"role": "user", "content": "run plugin"}],
tools=_load_entry_point_plugin(_LegacyErrorPluginTool, tmp_path), tools=_load_entry_point_plugin(_LegacyErrorPluginTool, tmp_path),
model="test-model", 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={}), 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"}], initial_messages=[{"role": "user", "content": "run plugin"}],
tools=_load_entry_point_plugin(_StructuredSuccessPluginTool, tmp_path), tools=_load_entry_point_plugin(_StructuredSuccessPluginTool, tmp_path),
model="test-model", model="test-model",
@ -449,8 +452,8 @@ async def test_runner_blocks_repeated_external_fetches():
tools.get_definitions.return_value = [] tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="page content") tools.execute = AsyncMock(return_value="page content")
runner = AgentRunner(provider) runner = AgentRunner()
result = await runner.run(AgentRunSpec( result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "research task"}], initial_messages=[{"role": "user", "content": "research task"}],
tools=tools, tools=tools,
model="test-model", model="test-model",

View File

@ -39,10 +39,10 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
assert loop.provider is new_provider assert loop.provider is new_provider
assert loop.model == "new-model" assert loop.model == "new-model"
assert loop.context_window_tokens == 2000 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.provider is new_provider
assert loop.subagents.model == "new-model" 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.provider is new_provider
assert loop.consolidator.model == "new-model" assert loop.consolidator.model == "new-model"
assert loop.consolidator.context_window_tokens == 2000 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.provider is new_provider
assert runtime.model == "new-model" assert runtime.model == "new-model"
assert loop.provider is new_provider 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( def test_settings_context_window_refreshes_runtime_state(

View File

@ -107,9 +107,9 @@ def test_model_preset_setter_replaces_provider_from_snapshot(tmp_path) -> None:
loop.set_model_preset("deep") loop.set_model_preset("deep")
assert loop.provider is new_provider 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.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.consolidator.provider is new_provider
assert loop.model == "anthropic/claude-opus-4-5" assert loop.model == "anthropic/claude-opus-4-5"
assert loop.context_window_tokens == 200_000 assert loop.context_window_tokens == 200_000

View File

@ -88,7 +88,7 @@ class TestSetProvider:
sm.set_provider(new_provider, "new-model") sm.set_provider(new_provider, "new-model")
assert sm.provider is new_provider assert sm.provider is new_provider
assert sm.model == "new-model" assert sm.model == "new-model"
assert sm.runner.provider is new_provider assert not hasattr(sm.runner, "provider")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -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): async def fake_process_direct(message, *, session_key, hooks):
assert bot._loop.provider is override_provider 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.model == "openai/gpt-4.1-mini"
assert bot._loop.context_window_tokens == 4096 assert bot._loop.context_window_tokens == 4096
return OutboundMessage(channel="cli", chat_id="direct", content="ok") 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, model_preset=None,
) )
assert bot._loop.provider is original_provider 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.model == original_model
assert bot._loop._provider_signature == original_signature assert bot._loop._provider_signature == original_signature