refactor(agent): make runner consume required runtime

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