refactor(agent): make runner consume required runtime
This commit is contained in:
@@ -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
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user