Merge origin/main into feat/show-reasoning
Resolves conflicts after main landed the state-machine turn refactor and the test_runner.py 9-file split: - nanobot/agent/loop.py: take main's `_state_build`/`_persist_user_message_early` flow; restore the `reasoning: bool` parameter on `_build_bus_progress_callback` so the loop hook can mark progress as reasoning-channel without coupling to the answer stream. - nanobot/cli/stream.py: keep main's configurable `bot_name`/`bot_icon` header while preserving the PR's `transient=True` Live + `self._console` routing + `_renderable()` final-render path that fixed TUI duplication. - tests/agent/test_runner.py was deleted on main and split into 9 focused files; relocated all 6 reasoning tests into a new `test_runner_reasoning.py` matching the new layout, deduplicated the per-test `ReasoningHook` boilerplate through a shared `_RecordingHook` helper. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Shared fixtures and helpers for agent tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
def make_provider(
|
||||
default_model: str = "test-model",
|
||||
*,
|
||||
max_tokens: int = 4096,
|
||||
spec: bool = True,
|
||||
) -> MagicMock:
|
||||
"""Create a spec-limited LLM provider mock."""
|
||||
mock_type = MagicMock(spec=LLMProvider) if spec else MagicMock()
|
||||
provider = mock_type
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
)
|
||||
provider.estimate_prompt_tokens.return_value = (10_000, "test")
|
||||
return provider
|
||||
|
||||
|
||||
def make_loop(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
model: str = "test-model",
|
||||
context_window_tokens: int = 128_000,
|
||||
session_ttl_minutes: int = 0,
|
||||
max_messages: int = 120,
|
||||
unified_session: bool = False,
|
||||
mcp_servers: dict | None = None,
|
||||
tools_config=None,
|
||||
model_presets: dict | None = None,
|
||||
hooks: list | None = None,
|
||||
provider: MagicMock | None = None,
|
||||
patch_deps: bool = False,
|
||||
) -> AgentLoop:
|
||||
"""Create a real AgentLoop for testing.
|
||||
|
||||
Args:
|
||||
patch_deps: If True, patch ContextBuilder/SessionManager/SubagentManager
|
||||
during construction (needed when workspace has no real files).
|
||||
"""
|
||||
bus = MessageBus()
|
||||
if provider is None:
|
||||
provider = make_provider(default_model=model)
|
||||
|
||||
kwargs = dict(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model=model,
|
||||
context_window_tokens=context_window_tokens,
|
||||
session_ttl_minutes=session_ttl_minutes,
|
||||
max_messages=max_messages,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
if mcp_servers is not None:
|
||||
kwargs["mcp_servers"] = mcp_servers
|
||||
if tools_config is not None:
|
||||
kwargs["tools_config"] = tools_config
|
||||
if model_presets is not None:
|
||||
kwargs["model_presets"] = model_presets
|
||||
if hooks is not None:
|
||||
kwargs["hooks"] = hooks
|
||||
|
||||
if patch_deps:
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
return AgentLoop(**kwargs)
|
||||
return AgentLoop(**kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loop_factory(tmp_path):
|
||||
"""Fixture providing a factory for creating AgentLoop instances."""
|
||||
def _factory(**kwargs):
|
||||
return make_loop(tmp_path, **kwargs)
|
||||
return _factory
|
||||
@@ -1,241 +0,0 @@
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.ask import AskUserInterrupt, AskUserTool
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.schema import tool_parameters_schema
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest
|
||||
|
||||
|
||||
def _make_provider(chat_with_retry):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
kwargs.pop("on_content_delta", None)
|
||||
return await chat_with_retry(**kwargs)
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
return provider
|
||||
|
||||
|
||||
def test_ask_user_tool_schema_and_interrupt():
|
||||
tool = AskUserTool()
|
||||
schema = tool.to_schema()["function"]
|
||||
|
||||
assert schema["name"] == "ask_user"
|
||||
assert "question" in schema["parameters"]["required"]
|
||||
assert schema["parameters"]["properties"]["options"]["type"] == "array"
|
||||
|
||||
with pytest.raises(AskUserInterrupt) as exc:
|
||||
asyncio.run(tool.execute("Continue?", options=["Yes", "No"]))
|
||||
|
||||
assert exc.value.question == "Continue?"
|
||||
assert exc.value.options == ["Yes", "No"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_pauses_on_ask_user_without_executing_later_tools():
|
||||
@tool_parameters(tool_parameters_schema(required=[]))
|
||||
class LaterTool(Tool):
|
||||
called = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "later"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Should not run after ask_user pauses the turn."
|
||||
|
||||
async def execute(self, **kwargs):
|
||||
self.called = True
|
||||
return "later result"
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={"question": "Install this package?", "options": ["Yes", "No"]},
|
||||
),
|
||||
ToolCallRequest(id="call_later", name="later", arguments={}),
|
||||
],
|
||||
)
|
||||
|
||||
later = LaterTool()
|
||||
tools = ToolRegistry()
|
||||
tools.register(AskUserTool())
|
||||
tools.register(later)
|
||||
|
||||
result = await AgentRunner(_make_provider(chat_with_retry)).run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "continue"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=16_000,
|
||||
concurrent_tools=True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "ask_user"
|
||||
assert result.final_content == "Install this package?"
|
||||
assert "ask_user" in result.tools_used
|
||||
assert later.called is False
|
||||
assert result.messages[-1]["role"] == "assistant"
|
||||
tool_calls = result.messages[-1]["tool_calls"]
|
||||
assert [tool_call["function"]["name"] for tool_call in tool_calls] == ["ask_user"]
|
||||
assert not any(message.get("name") == "ask_user" for message in result.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_text_fallback_resumes_with_next_message(tmp_path):
|
||||
seen_messages: list[list[dict]] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
seen_messages.append(kwargs["messages"])
|
||||
if len(seen_messages) == 1:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
return LLMResponse(content="Skipped install.", usage={})
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
pass
|
||||
|
||||
async def on_stream_end(**kwargs) -> None:
|
||||
pass
|
||||
|
||||
first = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="set it up"),
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.content == "Install the optional package?\n\n1. Install\n2. Skip"
|
||||
assert first.buttons == []
|
||||
assert "_streamed" not in first.metadata
|
||||
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
assert any(message.get("role") == "assistant" and message.get("tool_calls") for message in session.messages)
|
||||
assert not any(message.get("role") == "tool" and message.get("name") == "ask_user" for message in session.messages)
|
||||
|
||||
second = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="Skip")
|
||||
)
|
||||
|
||||
assert second is not None
|
||||
assert second.content == "Skipped install."
|
||||
assert any(
|
||||
message.get("role") == "tool"
|
||||
and message.get("name") == "ask_user"
|
||||
and message.get("content") == "Skip"
|
||||
for message in seen_messages[-1]
|
||||
)
|
||||
assert not any(
|
||||
message.get("role") == "user" and message.get("content") == "Skip"
|
||||
for message in session.messages
|
||||
)
|
||||
assert any(
|
||||
message.get("role") == "tool"
|
||||
and message.get("name") == "ask_user"
|
||||
and message.get("content") == "Skip"
|
||||
for message in session.messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_keeps_buttons_for_telegram(tmp_path):
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
response = await loop._process_message(
|
||||
InboundMessage(channel="telegram", sender_id="user", chat_id="123", content="set it up")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "Install the optional package?"
|
||||
assert response.buttons == [["Install", "Skip"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_user_keeps_buttons_for_websocket(tmp_path):
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="",
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_ask",
|
||||
name="ask_user",
|
||||
arguments={
|
||||
"question": "Install the optional package?",
|
||||
"options": ["Install", "Skip"],
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_make_provider(chat_with_retry),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
response = await loop._process_message(
|
||||
InboundMessage(channel="websocket", sender_id="user", chat_id="123", content="set it up")
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "Install the optional package?"
|
||||
assert response.buttons == [["Install", "Skip"]]
|
||||
@@ -1020,14 +1020,14 @@ class TestSummaryPersistence:
|
||||
|
||||
assert summary is not None
|
||||
assert "User said hello." in summary
|
||||
assert "Inactive for" in summary
|
||||
# Metadata should be cleaned up after consumption
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
assert "Previous conversation summary" in summary
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_cleanup_no_leak(self, tmp_path):
|
||||
"""_last_summary should be removed from metadata after being consumed."""
|
||||
async def test_metadata_persists_for_restart(self, tmp_path):
|
||||
"""_last_summary stays in metadata so it survives process restarts."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
_add_turns(session, 6, prefix="hello")
|
||||
@@ -1046,14 +1046,14 @@ class TestSummaryPersistence:
|
||||
loop.sessions.invalidate("cli:test")
|
||||
reloaded = loop.sessions.get_or_create("cli:test")
|
||||
|
||||
# First call: consumes from metadata
|
||||
# Every call returns the summary from metadata (no _consumed_keys gate)
|
||||
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary is not None
|
||||
|
||||
# Second call: no summary (already consumed)
|
||||
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary2 is None
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
assert summary2 is not None
|
||||
assert "Summary." in summary2
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1081,6 +1081,79 @@ class TestSummaryPersistence:
|
||||
# In-memory path is taken (no restart)
|
||||
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary is not None
|
||||
# Metadata should also be cleaned up
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
# _last_summary persists in metadata for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_summary_overrides_old(self, tmp_path):
|
||||
"""A fresh archive writes a new summary that replaces the old one."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
_add_turns(session, 6, prefix="hello")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def _fake_archive(messages):
|
||||
return "First summary."
|
||||
|
||||
loop.consolidator.archive = _fake_archive
|
||||
await loop.auto_compact._archive("cli:test")
|
||||
|
||||
# Consume the first summary via hot path
|
||||
_, summary1 = loop.auto_compact.prepare_session(
|
||||
loop.sessions.get_or_create("cli:test"), "cli:test"
|
||||
)
|
||||
assert summary1 is not None
|
||||
assert "First summary." in summary1
|
||||
assert "cli:test" not in loop.auto_compact._summaries # popped by hot path
|
||||
|
||||
# Add new messages and archive again (simulating a later turn)
|
||||
_add_turns(session, 4, prefix="world")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def _fake_archive2(messages):
|
||||
return "Second summary."
|
||||
|
||||
loop.consolidator.archive = _fake_archive2
|
||||
await loop.auto_compact._archive("cli:test")
|
||||
|
||||
# The second archive writes a new summary
|
||||
assert "cli:test" in loop.auto_compact._summaries
|
||||
|
||||
# prepare_session must return the new summary
|
||||
reloaded = loop.sessions.get_or_create("cli:test")
|
||||
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert summary2 is not None
|
||||
assert "Second summary." in summary2
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_command_clears_last_summary(self, tmp_path):
|
||||
"""/new should clear _last_summary so the new session starts fresh."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
_add_turns(session, 6, prefix="hello")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
async def _fake_archive(messages):
|
||||
return "Old summary."
|
||||
|
||||
loop.consolidator.archive = _fake_archive
|
||||
await loop.auto_compact._archive("cli:test")
|
||||
|
||||
# Verify summary exists before /new
|
||||
reloaded = loop.sessions.get_or_create("cli:test")
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
|
||||
# Simulate /new command
|
||||
session.clear()
|
||||
loop.sessions.save(session)
|
||||
loop.sessions.invalidate(session.key)
|
||||
|
||||
# After /new, metadata should no longer contain _last_summary
|
||||
fresh = loop.sessions.get_or_create("cli:test")
|
||||
assert "_last_summary" not in fresh.metadata
|
||||
await loop.close_mcp()
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
"""Direct unit tests for AutoCompact class methods in isolation."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
def _make_session(
|
||||
key: str = "cli:test",
|
||||
messages: list | None = None,
|
||||
last_consolidated: int = 0,
|
||||
updated_at: datetime | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Session:
|
||||
"""Create a Session with sensible defaults for testing."""
|
||||
session = Session(
|
||||
key=key,
|
||||
messages=messages or [],
|
||||
metadata=metadata or {},
|
||||
last_consolidated=last_consolidated,
|
||||
)
|
||||
if updated_at is not None:
|
||||
session.updated_at = updated_at
|
||||
return session
|
||||
|
||||
|
||||
def _make_autocompact(
|
||||
ttl: int = 15,
|
||||
sessions: SessionManager | None = None,
|
||||
consolidator: MagicMock | None = None,
|
||||
) -> AutoCompact:
|
||||
"""Create an AutoCompact with mock dependencies."""
|
||||
if sessions is None:
|
||||
sessions = MagicMock(spec=SessionManager)
|
||||
if consolidator is None:
|
||||
consolidator = MagicMock()
|
||||
consolidator.archive = AsyncMock(return_value="Summary.")
|
||||
return AutoCompact(
|
||||
sessions=sessions,
|
||||
consolidator=consolidator,
|
||||
session_ttl_minutes=ttl,
|
||||
)
|
||||
|
||||
|
||||
def _add_turns(session: Session, turns: int, *, prefix: str = "msg") -> None:
|
||||
"""Append simple user/assistant turns to a session."""
|
||||
for i in range(turns):
|
||||
session.add_message("user", f"{prefix} user {i}")
|
||||
session.add_message("assistant", f"{prefix} assistant {i}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# __init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""Test AutoCompact.__init__ stores constructor arguments correctly."""
|
||||
|
||||
def test_stores_ttl(self):
|
||||
"""_ttl should match session_ttl_minutes argument."""
|
||||
ac = _make_autocompact(ttl=30)
|
||||
assert ac._ttl == 30
|
||||
|
||||
def test_default_ttl_is_zero(self):
|
||||
"""Default TTL should be 0."""
|
||||
ac = _make_autocompact(ttl=0)
|
||||
assert ac._ttl == 0
|
||||
|
||||
def test_archiving_set_is_empty(self):
|
||||
"""_archiving should start as an empty set."""
|
||||
ac = _make_autocompact()
|
||||
assert ac._archiving == set()
|
||||
|
||||
def test_summaries_dict_is_empty(self):
|
||||
"""_summaries should start as an empty dict."""
|
||||
ac = _make_autocompact()
|
||||
assert ac._summaries == {}
|
||||
|
||||
def test_stores_sessions_reference(self):
|
||||
"""sessions attribute should reference the passed SessionManager."""
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
ac = _make_autocompact(sessions=mock_sm)
|
||||
assert ac.sessions is mock_sm
|
||||
|
||||
def test_stores_consolidator_reference(self):
|
||||
"""consolidator attribute should reference the passed Consolidator."""
|
||||
mock_c = MagicMock()
|
||||
ac = _make_autocompact(consolidator=mock_c)
|
||||
assert ac.consolidator is mock_c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_expired
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsExpired:
|
||||
"""Test AutoCompact._is_expired edge cases."""
|
||||
|
||||
def test_ttl_zero_always_false(self):
|
||||
"""TTL=0 means auto-compact is disabled; always returns False."""
|
||||
ac = _make_autocompact(ttl=0)
|
||||
old = datetime.now() - timedelta(days=365)
|
||||
assert ac._is_expired(old) is False
|
||||
|
||||
def test_none_timestamp_returns_false(self):
|
||||
"""None timestamp should return False."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
assert ac._is_expired(None) is False
|
||||
|
||||
def test_empty_string_timestamp_returns_false(self):
|
||||
"""Empty string timestamp should return False (falsy)."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
assert ac._is_expired("") is False
|
||||
|
||||
def test_exactly_at_boundary_is_expired(self):
|
||||
"""Timestamp exactly at TTL boundary should be expired (>=)."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
now = datetime(2026, 1, 1, 12, 0, 0)
|
||||
ts = now - timedelta(minutes=15)
|
||||
assert ac._is_expired(ts, now=now) is True
|
||||
|
||||
def test_just_under_boundary_not_expired(self):
|
||||
"""Timestamp just under TTL boundary should NOT be expired."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
now = datetime(2026, 1, 1, 12, 0, 0)
|
||||
ts = now - timedelta(minutes=14, seconds=59)
|
||||
assert ac._is_expired(ts, now=now) is False
|
||||
|
||||
def test_iso_string_parses_correctly(self):
|
||||
"""ISO format string timestamp should be parsed and evaluated."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
now = datetime(2026, 1, 1, 12, 0, 0)
|
||||
ts = (now - timedelta(minutes=20)).isoformat()
|
||||
assert ac._is_expired(ts, now=now) is True
|
||||
|
||||
def test_custom_now_parameter(self):
|
||||
"""Custom 'now' parameter should override datetime.now()."""
|
||||
ac = _make_autocompact(ttl=10)
|
||||
ts = datetime(2026, 1, 1, 10, 0, 0)
|
||||
# 9 minutes later → not expired
|
||||
now_under = datetime(2026, 1, 1, 10, 9, 0)
|
||||
assert ac._is_expired(ts, now=now_under) is False
|
||||
# 10 minutes later → expired
|
||||
now_over = datetime(2026, 1, 1, 10, 10, 0)
|
||||
assert ac._is_expired(ts, now=now_over) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatSummary:
|
||||
"""Test AutoCompact._format_summary static method."""
|
||||
|
||||
def test_contains_isoformat_timestamp(self):
|
||||
"""Output should contain last_active as isoformat."""
|
||||
last_active = datetime(2026, 5, 13, 14, 30, 0)
|
||||
result = AutoCompact._format_summary("Some text", last_active)
|
||||
assert "2026-05-13T14:30:00" in result
|
||||
|
||||
def test_contains_summary_text(self):
|
||||
"""Output should contain the provided text verbatim."""
|
||||
last_active = datetime(2026, 1, 1)
|
||||
result = AutoCompact._format_summary("User discussed Python.", last_active)
|
||||
assert "User discussed Python." in result
|
||||
|
||||
def test_output_starts_with_label(self):
|
||||
"""Output should start with the standard prefix."""
|
||||
last_active = datetime(2026, 1, 1)
|
||||
result = AutoCompact._format_summary("text", last_active)
|
||||
assert result.startswith("Previous conversation summary (last active ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _split_unconsolidated
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitUnconsolidated:
|
||||
"""Test AutoCompact._split_unconsolidated splitting logic."""
|
||||
|
||||
def test_empty_session_returns_both_empty(self):
|
||||
"""Empty session should return ([], [])."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session(messages=[])
|
||||
archive, kept = ac._split_unconsolidated(session)
|
||||
assert archive == []
|
||||
assert kept == []
|
||||
|
||||
def test_all_messages_archivable_when_more_than_suffix(self):
|
||||
"""Session with many messages should archive a prefix and keep suffix."""
|
||||
ac = _make_autocompact()
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
archive, kept = ac._split_unconsolidated(session)
|
||||
assert len(archive) > 0
|
||||
assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
|
||||
|
||||
def test_fewer_messages_than_suffix_returns_empty_archive(self):
|
||||
"""Session with fewer messages than suffix should have empty archive."""
|
||||
ac = _make_autocompact()
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(3)]
|
||||
session = _make_session(messages=msgs)
|
||||
archive, kept = ac._split_unconsolidated(session)
|
||||
assert archive == []
|
||||
assert len(kept) == len(msgs)
|
||||
|
||||
def test_respects_last_consolidated_offset(self):
|
||||
"""Only messages after last_consolidated should be considered."""
|
||||
ac = _make_autocompact()
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
# First 10 are already consolidated
|
||||
session = _make_session(messages=msgs, last_consolidated=10)
|
||||
archive, kept = ac._split_unconsolidated(session)
|
||||
# Only the tail of 10 messages is considered for splitting
|
||||
assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in kept)
|
||||
assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in archive)
|
||||
|
||||
def test_retain_recent_legal_suffix_keeps_last_n(self):
|
||||
"""The kept suffix should be at most _RECENT_SUFFIX_MESSAGES long."""
|
||||
ac = _make_autocompact()
|
||||
# 20 user messages = 20 messages total, all after last_consolidated=0
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
archive, kept = ac._split_unconsolidated(session)
|
||||
assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES
|
||||
assert len(archive) == len(msgs) - len(kept)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_expired
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckExpired:
|
||||
"""Test AutoCompact.check_expired scheduling logic."""
|
||||
|
||||
def test_empty_sessions_list(self):
|
||||
"""No sessions → schedule_background should never be called."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
mock_sm.list_sessions.return_value = []
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_expired_session_schedules_background(self):
|
||||
"""Expired session should trigger schedule_background."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_called_once()
|
||||
assert "cli:old" in ac._archiving
|
||||
|
||||
def test_active_session_key_skips(self):
|
||||
"""Session in active_session_keys should be skipped."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:busy", "updated_at": old_ts}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler, active_session_keys={"cli:busy"})
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_session_already_in_archiving_skips(self):
|
||||
"""Session already in _archiving set should be skipped."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:dup", "updated_at": old_ts}]
|
||||
ac.sessions = mock_sm
|
||||
ac._archiving.add("cli:dup")
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_session_with_no_key_skips(self):
|
||||
"""Session info with empty/missing key should be skipped."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
mock_sm.list_sessions.return_value = [{"key": "", "updated_at": "old"}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_session_with_missing_key_field_skips(self):
|
||||
"""Session info dict without 'key' field should be skipped."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
mock_sm.list_sessions.return_value = [{"updated_at": "old"}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArchive:
|
||||
"""Test AutoCompact._archive async method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_session_updates_timestamp_no_archive_call(self):
|
||||
"""Empty session should refresh updated_at and not call consolidator.archive."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
empty_session = _make_session(messages=[])
|
||||
mock_sm.get_or_create.return_value = empty_session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(return_value="Summary.")
|
||||
|
||||
await ac._archive("cli:test")
|
||||
|
||||
ac.consolidator.archive.assert_not_called()
|
||||
mock_sm.save.assert_called_once_with(empty_session)
|
||||
# updated_at was refreshed
|
||||
assert empty_session.updated_at > datetime.now() - timedelta(seconds=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_returns_empty_string_no_summary_stored(self):
|
||||
"""If archive returns empty string, no summary should be stored."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(return_value="")
|
||||
|
||||
await ac._archive("cli:test")
|
||||
|
||||
assert "cli:test" not in ac._summaries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_returns_nothing_no_summary_stored(self):
|
||||
"""If archive returns '(nothing)', no summary should be stored."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(return_value="(nothing)")
|
||||
|
||||
await ac._archive("cli:test")
|
||||
|
||||
assert "cli:test" not in ac._summaries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_exception_caught_key_removed_from_archiving(self):
|
||||
"""If archive raises, exception is caught and key removed from _archiving."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
|
||||
# Should not raise
|
||||
await ac._archive("cli:test")
|
||||
|
||||
assert "cli:test" not in ac._archiving
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_archive_stores_summary_in_summaries_and_metadata(self):
|
||||
"""Successful archive should store summary in _summaries dict and metadata."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
last_active = datetime(2026, 5, 13, 10, 0, 0)
|
||||
session = _make_session(messages=msgs, updated_at=last_active)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(return_value="User discussed AI.")
|
||||
|
||||
await ac._archive("cli:test")
|
||||
|
||||
# _summaries
|
||||
entry = ac._summaries.get("cli:test")
|
||||
assert entry is not None
|
||||
assert entry[0] == "User discussed AI."
|
||||
assert entry[1] == last_active
|
||||
# metadata
|
||||
meta = session.metadata.get("_last_summary")
|
||||
assert meta is not None
|
||||
assert meta["text"] == "User discussed AI."
|
||||
assert "last_active" in meta
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finally_block_always_removes_from_archiving(self):
|
||||
"""Finally block should always remove key from _archiving, even on error."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("fail"))
|
||||
|
||||
# Pre-add key to archiving to verify it gets removed
|
||||
ac._archiving.add("cli:test")
|
||||
await ac._archive("cli:test")
|
||||
assert "cli:test" not in ac._archiving
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finally_removes_from_archiving_on_success(self):
|
||||
"""Finally block should remove key from _archiving on success too."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)]
|
||||
session = _make_session(messages=msgs)
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
ac.consolidator.archive = AsyncMock(return_value="Summary.")
|
||||
|
||||
ac._archiving.add("cli:test")
|
||||
await ac._archive("cli:test")
|
||||
assert "cli:test" not in ac._archiving
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrepareSession:
|
||||
"""Test AutoCompact.prepare_session logic."""
|
||||
|
||||
def test_key_in_archiving_reloads_session(self):
|
||||
"""If key is in _archiving, session should be reloaded via get_or_create."""
|
||||
ac = _make_autocompact()
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
reloaded = _make_session(key="cli:test")
|
||||
mock_sm.get_or_create.return_value = reloaded
|
||||
ac.sessions = mock_sm
|
||||
ac._archiving.add("cli:test")
|
||||
|
||||
original_session = _make_session()
|
||||
result_session, summary = ac.prepare_session(original_session, "cli:test")
|
||||
|
||||
mock_sm.get_or_create.assert_called_once_with("cli:test")
|
||||
assert result_session is reloaded
|
||||
|
||||
def test_expired_session_reloads(self):
|
||||
"""If session is expired, it should be reloaded via get_or_create."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
reloaded = _make_session(key="cli:test", updated_at=datetime.now())
|
||||
mock_sm.get_or_create.return_value = reloaded
|
||||
ac.sessions = mock_sm
|
||||
|
||||
old_session = _make_session(updated_at=datetime.now() - timedelta(minutes=20))
|
||||
result_session, summary = ac.prepare_session(old_session, "cli:test")
|
||||
|
||||
mock_sm.get_or_create.assert_called_once_with("cli:test")
|
||||
assert result_session is reloaded
|
||||
|
||||
def test_hot_path_summary_from_summaries(self):
|
||||
"""Summary from _summaries dict should be returned (hot path)."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session()
|
||||
last_active = datetime(2026, 5, 13, 14, 0, 0)
|
||||
ac._summaries["cli:test"] = ("Hot summary.", last_active)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Hot summary." in summary
|
||||
assert "Previous conversation summary" in summary
|
||||
|
||||
def test_hot_path_pops_summary_one_shot(self):
|
||||
"""Hot path should pop the summary (one-shot; second call returns None)."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session()
|
||||
last_active = datetime(2026, 1, 1)
|
||||
ac._summaries["cli:test"] = ("One-shot.", last_active)
|
||||
|
||||
_, summary1 = ac.prepare_session(session, "cli:test")
|
||||
assert summary1 is not None
|
||||
# Second call: hot path entry was popped
|
||||
_, summary2 = ac.prepare_session(session, "cli:test")
|
||||
assert summary2 is None
|
||||
|
||||
def test_cold_path_summary_from_metadata(self):
|
||||
"""When _summaries is empty, summary should come from metadata (cold path)."""
|
||||
ac = _make_autocompact()
|
||||
last_active = datetime(2026, 5, 13, 14, 0, 0)
|
||||
session = _make_session(metadata={
|
||||
"_last_summary": {
|
||||
"text": "Cold summary.",
|
||||
"last_active": last_active.isoformat(),
|
||||
},
|
||||
})
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is not None
|
||||
assert "Cold summary." in summary
|
||||
|
||||
def test_no_summary_available_returns_none(self):
|
||||
"""When no summary is available, should return (session, None)."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session()
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
|
||||
def test_cold_path_metadata_not_dict_returns_none(self):
|
||||
"""If metadata _last_summary is not a dict, should return None summary."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session(metadata={"_last_summary": "not a dict"})
|
||||
|
||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
||||
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
|
||||
def test_hot_path_takes_priority_over_metadata(self):
|
||||
"""Hot path (_summaries) should take priority over metadata."""
|
||||
ac = _make_autocompact()
|
||||
session = _make_session(metadata={
|
||||
"_last_summary": {
|
||||
"text": "Cold summary.",
|
||||
"last_active": datetime(2026, 1, 1).isoformat(),
|
||||
},
|
||||
})
|
||||
last_active = datetime(2026, 5, 13, 14, 0, 0)
|
||||
ac._summaries["cli:test"] = ("Hot summary.", last_active)
|
||||
|
||||
_, summary = ac.prepare_session(session, "cli:test")
|
||||
assert "Hot summary." in summary
|
||||
# After hot path pops, cold path would kick in on next call
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||
|
||||
|
||||
class _ContextTool:
|
||||
def __init__(self):
|
||||
self.last_ctx = None
|
||||
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
self.last_ctx = ctx
|
||||
|
||||
|
||||
def test_context_aware_sets_request_context():
|
||||
tool = _ContextTool()
|
||||
ctx = RequestContext(channel="test", chat_id="123", session_key="test:123")
|
||||
tool.set_context(ctx)
|
||||
assert tool.last_ctx.channel == "test"
|
||||
|
||||
|
||||
def test_context_tool_is_instance_of_context_aware():
|
||||
tool = _ContextTool()
|
||||
assert isinstance(tool, ContextAware)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Tests for ContextBuilder — system prompt and message assembly."""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _builder(tmp_path: Path, **kw) -> ContextBuilder:
|
||||
return ContextBuilder(workspace=tmp_path, **kw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_runtime_context (static)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildRuntimeContext:
|
||||
def test_time_only(self):
|
||||
ctx = ContextBuilder._build_runtime_context(None, None)
|
||||
assert "[Runtime Context" in ctx
|
||||
assert "[/Runtime Context]" in ctx
|
||||
assert "Current Time:" in ctx
|
||||
assert "Channel:" not in ctx
|
||||
|
||||
def test_with_channel_and_chat_id(self):
|
||||
ctx = ContextBuilder._build_runtime_context("telegram", "chat123")
|
||||
assert "Channel: telegram" in ctx
|
||||
assert "Chat ID: chat123" in ctx
|
||||
|
||||
def test_with_sender_id(self):
|
||||
ctx = ContextBuilder._build_runtime_context("cli", "direct", sender_id="user1")
|
||||
assert "Sender ID: user1" in ctx
|
||||
|
||||
def test_with_timezone(self):
|
||||
ctx = ContextBuilder._build_runtime_context(None, None, timezone="Asia/Shanghai")
|
||||
assert "Current Time:" in ctx
|
||||
|
||||
def test_no_channel_no_chat_id_omits_both(self):
|
||||
ctx = ContextBuilder._build_runtime_context(None, None)
|
||||
assert "Channel:" not in ctx
|
||||
assert "Chat ID:" not in ctx
|
||||
|
||||
def test_no_sender_id_omits(self):
|
||||
ctx = ContextBuilder._build_runtime_context("cli", "direct")
|
||||
assert "Sender ID:" not in ctx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _merge_message_content (static)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMergeMessageContent:
|
||||
def test_str_plus_str(self):
|
||||
result = ContextBuilder._merge_message_content("hello", "world")
|
||||
assert result == "hello\n\nworld"
|
||||
|
||||
def test_empty_left_plus_str(self):
|
||||
result = ContextBuilder._merge_message_content("", "world")
|
||||
assert result == "world"
|
||||
|
||||
def test_list_plus_list(self):
|
||||
left = [{"type": "text", "text": "a"}]
|
||||
right = [{"type": "text", "text": "b"}]
|
||||
result = ContextBuilder._merge_message_content(left, right)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "a"
|
||||
assert result[1]["text"] == "b"
|
||||
|
||||
def test_str_plus_list(self):
|
||||
right = [{"type": "text", "text": "b"}]
|
||||
result = ContextBuilder._merge_message_content("hello", right)
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "hello"
|
||||
assert result[1]["text"] == "b"
|
||||
|
||||
def test_list_plus_str(self):
|
||||
left = [{"type": "text", "text": "a"}]
|
||||
result = ContextBuilder._merge_message_content(left, "world")
|
||||
assert len(result) == 2
|
||||
assert result[0]["text"] == "a"
|
||||
assert result[1]["text"] == "world"
|
||||
|
||||
def test_none_plus_str(self):
|
||||
result = ContextBuilder._merge_message_content(None, "hello")
|
||||
assert result == [{"type": "text", "text": "hello"}]
|
||||
|
||||
def test_str_plus_none(self):
|
||||
result = ContextBuilder._merge_message_content("hello", None)
|
||||
assert result == [{"type": "text", "text": "hello"}]
|
||||
|
||||
def test_none_plus_none(self):
|
||||
result = ContextBuilder._merge_message_content(None, None)
|
||||
assert result == []
|
||||
|
||||
def test_list_items_not_dicts_wrapped(self):
|
||||
result = ContextBuilder._merge_message_content(["raw_item"], None)
|
||||
assert result == [{"type": "text", "text": "raw_item"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_bootstrap_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadBootstrapFiles:
|
||||
def test_no_bootstrap_files(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
assert builder._load_bootstrap_files() == ""
|
||||
|
||||
def test_agents_md(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Be helpful.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._load_bootstrap_files()
|
||||
assert "## AGENTS.md" in result
|
||||
assert "Be helpful." in result
|
||||
|
||||
def test_multiple_bootstrap_files(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Rules.", encoding="utf-8")
|
||||
(tmp_path / "SOUL.md").write_text("Soul.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._load_bootstrap_files()
|
||||
assert "## AGENTS.md" in result
|
||||
assert "## SOUL.md" in result
|
||||
assert "Rules." in result
|
||||
assert "Soul." in result
|
||||
|
||||
def test_all_bootstrap_files(self, tmp_path):
|
||||
for name in ContextBuilder.BOOTSTRAP_FILES:
|
||||
(tmp_path / name).write_text(f"Content of {name}", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._load_bootstrap_files()
|
||||
for name in ContextBuilder.BOOTSTRAP_FILES:
|
||||
assert f"## {name}" in result
|
||||
|
||||
def test_utf8_content(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._load_bootstrap_files()
|
||||
assert "用中文回复" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_template_content (static)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsTemplateContent:
|
||||
def test_nonexistent_template_returns_false(self):
|
||||
assert ContextBuilder._is_template_content("anything", "nonexistent/path.md") is False
|
||||
|
||||
def test_content_matching_template(self):
|
||||
from importlib.resources import files as pkg_files
|
||||
tpl = pkg_files("nanobot") / "templates" / "memory" / "MEMORY.md"
|
||||
if not tpl.is_file():
|
||||
pytest.skip("MEMORY.md template not bundled")
|
||||
original = tpl.read_text(encoding="utf-8")
|
||||
assert ContextBuilder._is_template_content(original, "memory/MEMORY.md") is True
|
||||
|
||||
def test_modified_content_returns_false(self):
|
||||
from importlib.resources import files as pkg_files
|
||||
tpl = pkg_files("nanobot") / "templates" / "memory" / "MEMORY.md"
|
||||
if not tpl.is_file():
|
||||
pytest.skip("MEMORY.md template not bundled")
|
||||
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_user_content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildUserContent:
|
||||
def test_no_media_returns_string(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", None)
|
||||
assert result == "hello"
|
||||
|
||||
def test_empty_media_returns_string(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", [])
|
||||
assert result == "hello"
|
||||
|
||||
def test_nonexistent_media_file_returns_string(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", ["/nonexistent/image.png"])
|
||||
assert result == "hello"
|
||||
|
||||
def test_non_image_file_returns_string(self, tmp_path):
|
||||
txt = tmp_path / "doc.txt"
|
||||
txt.write_text("not an image", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", [str(txt)])
|
||||
assert result == "hello"
|
||||
|
||||
def test_valid_image_returns_list(self, tmp_path):
|
||||
png = tmp_path / "test.png"
|
||||
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", [str(png)])
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0]["type"] == "image_url"
|
||||
assert result[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
assert result[1]["type"] == "text"
|
||||
assert result[1]["text"] == "hello"
|
||||
|
||||
def test_image_meta_includes_path(self, tmp_path):
|
||||
png = tmp_path / "test.png"
|
||||
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||
builder = _builder(tmp_path)
|
||||
result = builder._build_user_content("hello", [str(png)])
|
||||
assert "_meta" in result[0]
|
||||
assert "path" in result[0]["_meta"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_system_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildSystemPrompt:
|
||||
def test_returns_nonempty_string(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt()
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_includes_identity_section(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt()
|
||||
assert "workspace" in result.lower() or "python" in result.lower()
|
||||
|
||||
def test_includes_bootstrap_files(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Be helpful and concise.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt()
|
||||
assert "Be helpful and concise." in result
|
||||
|
||||
def test_includes_session_summary(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt(session_summary="Previous chat about Python.")
|
||||
assert "Previous chat about Python." in result
|
||||
assert "[Archived Context Summary]" in result
|
||||
|
||||
def test_sections_separated_by_separator(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Rules.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt(session_summary="Summary.")
|
||||
assert "\n\n---\n\n" in result
|
||||
|
||||
def test_no_bootstrap_no_summary(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
result = builder.build_system_prompt()
|
||||
assert "## AGENTS.md" not in result
|
||||
assert "[Archived Context Summary]" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildMessages:
|
||||
def test_basic_empty_history(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages([], "hello")
|
||||
assert len(messages) == 2
|
||||
assert messages[0]["role"] == "system"
|
||||
assert messages[1]["role"] == "user"
|
||||
assert "hello" in str(messages[1]["content"])
|
||||
|
||||
def test_runtime_context_injected(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
|
||||
user_msg = str(messages[-1]["content"])
|
||||
assert "[Runtime Context" in user_msg
|
||||
assert "hello" in user_msg
|
||||
|
||||
def test_consecutive_same_role_merged(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "user", "content": "previous user message"}]
|
||||
messages = builder.build_messages(history, "new message")
|
||||
assert len(messages) == 2 # system + merged user
|
||||
assert "previous user message" in str(messages[1]["content"])
|
||||
assert "new message" in str(messages[1]["content"])
|
||||
|
||||
def test_different_role_appended(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "assistant", "content": "previous response"}]
|
||||
messages = builder.build_messages(history, "new message")
|
||||
assert len(messages) == 3 # system + assistant + user
|
||||
|
||||
def test_media_with_history(self, tmp_path):
|
||||
png = tmp_path / "img.png"
|
||||
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "assistant", "content": "see this"}]
|
||||
messages = builder.build_messages(history, "check image", media=[str(png)])
|
||||
user_msg = messages[-1]["content"]
|
||||
assert isinstance(user_msg, list)
|
||||
assert any(b.get("type") == "image_url" for b in user_msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_tool_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAddToolResult:
|
||||
def test_appends_tool_message(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
msgs = [{"role": "user", "content": "hello"}]
|
||||
result = builder.add_tool_result(msgs, "call_123", "read_file", "file content")
|
||||
assert len(result) == 2
|
||||
assert result[1]["role"] == "tool"
|
||||
assert result[1]["tool_call_id"] == "call_123"
|
||||
assert result[1]["name"] == "read_file"
|
||||
assert result[1]["content"] == "file content"
|
||||
|
||||
def test_returns_same_list(self, tmp_path):
|
||||
builder = _builder(tmp_path)
|
||||
msgs = []
|
||||
result = builder.add_tool_result(msgs, "id", "tool", "ok")
|
||||
assert result is msgs
|
||||
@@ -0,0 +1,19 @@
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
|
||||
def test_tool_loader_scope_memory_only_returns_memory_tools():
|
||||
loader = ToolLoader()
|
||||
registry = ToolRegistry()
|
||||
ctx = ToolContext(config=Config().tools, workspace="/tmp")
|
||||
loader.load(ctx, registry, scope="memory")
|
||||
|
||||
names = set(registry.tool_names)
|
||||
assert "read_file" in names
|
||||
assert "edit_file" in names
|
||||
assert "write_file" in names
|
||||
assert "list_dir" not in names
|
||||
assert "exec" not in names
|
||||
assert "message" not in names
|
||||
@@ -190,7 +190,8 @@ async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path,
|
||||
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
|
||||
assert pending is not None
|
||||
assert "User discussed project status." in pending
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
# _last_summary persists for restart survival.
|
||||
assert "_last_summary" in reloaded.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -207,7 +208,6 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
|
||||
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_any_await(
|
||||
session,
|
||||
session_summary="Previous conversation summary: earlier context",
|
||||
replay_max_messages=loop._max_messages,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Tests for AgentLoop integration with AgentRunner: streaming, think-filter, error handling, subagent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
return loop
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_max_iterations_message_stays_stable(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.execute = AsyncMock(return_value="ok")
|
||||
loop.max_iterations = 2
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([])
|
||||
|
||||
assert final_content == (
|
||||
"I reached the maximum number of tool call iterations (2) "
|
||||
"without completing the task. You can try breaking the task into smaller steps."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_stream_filter_handles_think_only_prefix_without_crashing(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
deltas: list[str] = []
|
||||
endings: list[bool] = []
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await on_content_delta("<think>hidden")
|
||||
await on_content_delta("</think>Hello")
|
||||
return LLMResponse(content="<think>hidden</think>Hello", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
async def on_stream_end(*, resuming: bool = False) -> None:
|
||||
endings.append(resuming)
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop(
|
||||
[],
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
)
|
||||
|
||||
assert final_content == "Hello"
|
||||
assert deltas == ["Hello"]
|
||||
assert endings == [False]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_stream_filter_hides_partial_trailing_think_prefix(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
deltas: list[str] = []
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await on_content_delta("Hello <thin")
|
||||
await on_content_delta("k>hidden</think>World")
|
||||
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_stream=on_stream)
|
||||
|
||||
assert final_content == "Hello World"
|
||||
assert deltas == ["Hello", " World"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_stream_filter_hides_complete_trailing_think_tag(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
deltas: list[str] = []
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await on_content_delta("Hello <think>")
|
||||
await on_content_delta("hidden</think>World")
|
||||
return LLMResponse(content="Hello <think>hidden</think>World", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
|
||||
async def on_stream(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([], on_stream=on_stream)
|
||||
|
||||
assert final_content == "Hello World"
|
||||
assert deltas == ["Hello", " World"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_retries_think_only_final_response(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(content="<think>hidden</think>", tool_calls=[], usage={})
|
||||
return LLMResponse(content="Recovered answer", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_with_retry = chat_with_retry
|
||||
|
||||
final_content, _, _, _, _ = await loop._run_agent_loop([])
|
||||
|
||||
assert final_content == "Recovered answer"
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streamed_flag_not_set_on_llm_error(tmp_path):
|
||||
"""When LLM errors during a streaming-capable channel interaction,
|
||||
_streamed must NOT be set so ChannelManager delivers the error."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
error_resp = LLMResponse(
|
||||
content="503 service unavailable", finish_reason="error", tool_calls=[], usage={},
|
||||
)
|
||||
loop.provider.chat_with_retry = AsyncMock(return_value=error_resp)
|
||||
loop.provider.chat_stream_with_retry = AsyncMock(return_value=error_resp)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="feishu", sender_id="u1", chat_id="c1", content="hi",
|
||||
)
|
||||
result = await loop._process_message(
|
||||
msg,
|
||||
on_stream=AsyncMock(),
|
||||
on_stream_end=AsyncMock(),
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "503" in result.content
|
||||
assert not result.metadata.get("_streamed"), \
|
||||
"_streamed must not be set when stop_reason is error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
tool_call_resp = LLMResponse(
|
||||
content="checking metadata",
|
||||
tool_calls=[ToolCallRequest(
|
||||
id="call_ssrf",
|
||||
name="exec",
|
||||
arguments={"command": "curl http://169.254.169.254/latest/meta-data/"},
|
||||
)],
|
||||
usage={},
|
||||
)
|
||||
provider.chat_stream_with_retry = AsyncMock(side_effect=[
|
||||
tool_call_resp,
|
||||
LLMResponse(
|
||||
content="I cannot access private URLs. Please share the local file.",
|
||||
tool_calls=[],
|
||||
usage={},
|
||||
),
|
||||
])
|
||||
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(return_value=(None, {}, None))
|
||||
loop.tools.execute = AsyncMock(return_value=(
|
||||
"Error: Command blocked by safety guard (internal/private URL detected)"
|
||||
))
|
||||
|
||||
result = await loop._process_message(
|
||||
InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="hi"),
|
||||
on_stream=AsyncMock(),
|
||||
on_stream_end=AsyncMock(),
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.content == "I cannot access private URLs. Please share the local file."
|
||||
assert result.metadata.get("_streamed") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_turn_after_llm_error_keeps_turn_boundary(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import _PERSISTED_MODEL_ERROR_PLACEHOLDER
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(content="429 rate limit exceeded", finish_reason="error", tool_calls=[], usage={}),
|
||||
LLMResponse(content="Recovered answer", tool_calls=[], usage={}),
|
||||
])
|
||||
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
first = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="first question")
|
||||
)
|
||||
assert first is not None
|
||||
assert first.content == "429 rate limit exceeded"
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
assert [
|
||||
{key: value for key, value in message.items() if key in {"role", "content"}}
|
||||
for message in session.messages
|
||||
] == [
|
||||
{"role": "user", "content": "first question"},
|
||||
{"role": "assistant", "content": _PERSISTED_MODEL_ERROR_PLACEHOLDER},
|
||||
]
|
||||
|
||||
second = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second question")
|
||||
)
|
||||
assert second is not None
|
||||
assert second.content == "Recovered answer"
|
||||
|
||||
request_messages = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
||||
non_system = [message for message in request_messages if message.get("role") != "system"]
|
||||
assert non_system[0]["role"] == "user"
|
||||
assert "first question" in non_system[0]["content"]
|
||||
assert non_system[1]["role"] == "assistant"
|
||||
assert _PERSISTED_MODEL_ERROR_PLACEHOLDER in non_system[1]["content"]
|
||||
assert non_system[2]["role"] == "user"
|
||||
assert "second question" in non_system[2]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, monkeypatch):
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
))
|
||||
mgr = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
async def fake_execute(self, **kwargs):
|
||||
return "tool result"
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.filesystem.ListDirTool.execute", fake_execute)
|
||||
|
||||
status = SubagentStatus(task_id="sub-1", label="label", task_description="do task", started_at=time.monotonic())
|
||||
await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"}, status)
|
||||
|
||||
mgr._announce_result.assert_awaited_once()
|
||||
args = mgr._announce_result.await_args.args
|
||||
assert args[3] == "Task completed but no final response was generated."
|
||||
assert args[5] == "ok"
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
|
||||
|
||||
class _ContextRecordingTool:
|
||||
@@ -15,18 +16,12 @@ class _ContextRecordingTool:
|
||||
def __init__(self) -> None:
|
||||
self.contexts: list[dict] = []
|
||||
|
||||
def set_context(
|
||||
self,
|
||||
channel: str,
|
||||
chat_id: str,
|
||||
metadata: dict | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> None:
|
||||
def set_context(self, ctx: RequestContext) -> None:
|
||||
self.contexts.append({
|
||||
"channel": channel,
|
||||
"chat_id": chat_id,
|
||||
"metadata": metadata,
|
||||
"session_key": session_key,
|
||||
"channel": ctx.channel,
|
||||
"chat_id": ctx.chat_id,
|
||||
"metadata": ctx.metadata,
|
||||
"session_key": ctx.session_key,
|
||||
})
|
||||
|
||||
async def execute(self, **_kwargs) -> str:
|
||||
@@ -37,6 +32,10 @@ class _Tools:
|
||||
def __init__(self, tool: _ContextRecordingTool) -> None:
|
||||
self.tool = tool
|
||||
|
||||
@property
|
||||
def tool_names(self) -> list[str]:
|
||||
return ["cron"]
|
||||
|
||||
def get(self, name: str):
|
||||
return self.tool if name == "cron" else None
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
"""Tests for core AgentRunner behavior: message passing, iteration limits,
|
||||
timeouts, empty-response handling, usage accumulation, and config passthrough."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
captured_second_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
reasoning_content="hidden reasoning",
|
||||
thinking_blocks=[{"type": "thinking", "thinking": "step"}],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "do task"},
|
||||
],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert result.tools_used == ["list_dir"]
|
||||
assert result.tool_events == [
|
||||
{"name": "list_dir", "status": "ok", "detail": "tool result"}
|
||||
]
|
||||
|
||||
assistant_messages = [
|
||||
msg for msg in captured_second_call
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls")
|
||||
]
|
||||
assert len(assistant_messages) == 1
|
||||
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
|
||||
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
|
||||
assert any(
|
||||
msg.get("role") == "tool" and msg.get("content") == "tool result"
|
||||
for msg in captured_second_call
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_max_iterations_fallback():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="still working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "max_iterations"
|
||||
assert result.final_content == (
|
||||
"I reached the maximum number of tool call iterations (2) "
|
||||
"without completing the task. You can try breaking the task into smaller steps."
|
||||
)
|
||||
assert result.messages[-1]["role"] == "assistant"
|
||||
assert result.messages[-1]["content"] == result.final_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_times_out_hung_llm_request():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
started = time.monotonic()
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hello"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
llm_timeout_s=0.05,
|
||||
))
|
||||
|
||||
assert (time.monotonic() - started) < 1.0
|
||||
assert result.stop_reason == "error"
|
||||
assert "timed out" in (result.final_content or "").lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_replaces_empty_tool_result_with_marker():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
captured_second_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="noop", arguments={})],
|
||||
usage={},
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||
assert tool_message["content"] == "(noop completed with no output)"
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
calls: list[dict] = []
|
||||
|
||||
async def chat_with_retry(*, messages, tools=None, **kwargs):
|
||||
calls.append({"messages": messages, "tools": tools})
|
||||
if len(calls) <= 2:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 1},
|
||||
)
|
||||
return LLMResponse(
|
||||
content="final answer",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 3, "completion_tokens": 7},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "final answer"
|
||||
# 2 silent retries (iterations 0,1) + finalization on iteration 1
|
||||
assert len(calls) == 3
|
||||
assert calls[0]["tools"] is not None
|
||||
assert calls[1]["tools"] is not None
|
||||
assert calls[2]["tools"] is None
|
||||
assert result.usage["prompt_tokens"] == 13
|
||||
assert result.usage["completion_tokens"] == 9
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
return LLMResponse(content=None, tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
assert result.stop_reason == "empty_final_response"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_empty_response_does_not_break_tool_chain():
|
||||
"""An empty intermediate response must not kill an ongoing 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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
call_count = 0
|
||||
|
||||
async def chat_with_retry(*, messages, tools=None, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[ToolCallRequest(id="tc1", name="read_file", arguments={"path": "a.txt"})],
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 5},
|
||||
)
|
||||
if call_count == 2:
|
||||
return LLMResponse(content=None, tool_calls=[], usage={"prompt_tokens": 10, "completion_tokens": 1})
|
||||
if call_count == 3:
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[ToolCallRequest(id="tc2", name="read_file", arguments={"path": "b.txt"})],
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 5},
|
||||
)
|
||||
return LLMResponse(
|
||||
content="Here are the results.",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 10, "completion_tokens": 10},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
provider.chat_stream_with_retry = chat_with_retry
|
||||
|
||||
async def fake_tool(name, args, **kw):
|
||||
return "file content"
|
||||
|
||||
tool_registry = MagicMock()
|
||||
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(
|
||||
initial_messages=[{"role": "user", "content": "read both files"}],
|
||||
tools=tool_registry,
|
||||
model="test-model",
|
||||
max_iterations=10,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "Here are the results."
|
||||
assert result.stop_reason == "completed"
|
||||
assert call_count == 4
|
||||
assert "read_file" in result.tools_used
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})],
|
||||
usage={"prompt_tokens": 100, "completion_tokens": 10, "cached_tokens": 80},
|
||||
)
|
||||
return LLMResponse(
|
||||
content="done",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="file content")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
# Usage should be accumulated across iterations
|
||||
assert result.usage["prompt_tokens"] == 300 # 100 + 200
|
||||
assert result.usage["completion_tokens"] == 30 # 10 + 20
|
||||
assert result.usage["cached_tokens"] == 230 # 80 + 150
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
|
||||
"""Regression: provider retry heartbeats must route through
|
||||
``retry_wait_callback``, not ``progress_callback``. Binding them to
|
||||
the progress callback (as an earlier runtime refactor did) caused
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
progress_cb = AsyncMock()
|
||||
retry_wait_cb = AsyncMock()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
await runner.run(AgentRunSpec(
|
||||
initial_messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "hi"},
|
||||
],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
retry_wait_callback=retry_wait_cb,
|
||||
))
|
||||
|
||||
assert captured["on_retry_wait"] is retry_wait_cb
|
||||
assert captured["on_retry_wait"] is not progress_cb
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config passthrough tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hi"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
temperature=0.7,
|
||||
))
|
||||
|
||||
assert captured["temperature"] == 0.7
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hi"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
max_tokens=8192,
|
||||
))
|
||||
|
||||
assert captured["max_tokens"] == 8192
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hi"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
reasoning_effort="high",
|
||||
))
|
||||
|
||||
assert captured["reasoning_effort"] == "high"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for AgentRunner error handling: tool errors, LLM errors,
|
||||
session message isolation, and tool result preservation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_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
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
fail_on_tool_error=True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "tool_error"
|
||||
assert result.error == "Error: RuntimeError: boom"
|
||||
assert result.tool_events == [
|
||||
{"name": "list_dir", "status": "error", "detail": "boom"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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,
|
||||
)
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
content="429 rate limit exceeded", finish_reason="error", tool_calls=[], usage={},
|
||||
))
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "hello"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=5,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "error"
|
||||
assert result.final_content == "429 rate limit exceeded"
|
||||
assistant_msgs = [m for m in result.messages if m.get("role") == "assistant"]
|
||||
assert all("429" not in (m.get("content") or "") for m in assistant_msgs), \
|
||||
"Error content should not appear in session messages"
|
||||
assert assistant_msgs[-1]["content"] == _PERSISTED_MODEL_ERROR_PLACEHOLDER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_tool_error_sets_final_content():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="read_file", arguments={"path": "x"})],
|
||||
usage={},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
fail_on_tool_error=True,
|
||||
))
|
||||
|
||||
assert result.final_content == "Error: RuntimeError: boom"
|
||||
assert result.stop_reason == "tool_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
return LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(id="tc1", name="read_file", arguments={"path": "a"}),
|
||||
ToolCallRequest(id="tc2", name="exec", arguments={"cmd": "bad"}),
|
||||
],
|
||||
usage={},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
provider.chat_stream_with_retry = chat_with_retry
|
||||
|
||||
call_idx = 0
|
||||
|
||||
async def fake_execute(name, args, **kw):
|
||||
nonlocal call_idx
|
||||
call_idx += 1
|
||||
if call_idx == 2:
|
||||
raise RuntimeError("boom")
|
||||
return "file content"
|
||||
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(side_effect=fake_execute)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do stuff"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
fail_on_tool_error=True,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "tool_error"
|
||||
# Both tool results must be in messages even though tc2 had a fatal error.
|
||||
tool_msgs = [m for m in result.messages if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 2
|
||||
assert tool_msgs[0]["tool_call_id"] == "tc1"
|
||||
assert tool_msgs[1]["tool_call_id"] == "tc2"
|
||||
# The assistant message with tool_calls must precede the tool results.
|
||||
asst_tc_idx = next(
|
||||
i for i, m in enumerate(result.messages)
|
||||
if m.get("role") == "assistant" and m.get("tool_calls")
|
||||
)
|
||||
tool_indices = [
|
||||
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
|
||||
]
|
||||
assert all(ti > asst_tc_idx for ti in tool_indices)
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Tests for AgentRunner context governance: backfill, orphan cleanup, microcompact, snip_history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
return loop
|
||||
|
||||
async def test_runner_uses_raw_messages_when_context_governance_fails():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
captured_messages[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
initial_messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
runner._snip_history = MagicMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert captured_messages == initial_messages
|
||||
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "tool call",
|
||||
"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "ls", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "tool output"},
|
||||
{"role": "assistant", "content": "after tool"},
|
||||
]
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
context_window_tokens=2000,
|
||||
context_block_limit=100,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_args, **_kwargs: (500, None))
|
||||
token_sizes = {
|
||||
"old user": 120,
|
||||
"tool call": 120,
|
||||
"tool output": 40,
|
||||
"after tool": 40,
|
||||
"system": 0,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
lambda msg: token_sizes.get(str(msg.get("content")), 40),
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
|
||||
# After the fix, the user message is recovered so the sequence is valid
|
||||
# for providers that require system → user (e.g. GLM error 1214).
|
||||
assert trimmed[0]["role"] == "system"
|
||||
non_system = [m for m in trimmed if m["role"] != "system"]
|
||||
assert non_system[0]["role"] == "user", f"Expected user after system, got {non_system[0]['role']}"
|
||||
async def test_backfill_missing_tool_results_inserts_error():
|
||||
"""Orphaned tool_use (no matching tool_result) should get a synthetic error."""
|
||||
from nanobot.agent.runner import AgentRunner, _BACKFILL_CONTENT
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_a", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
||||
{"id": "call_b", "type": "function", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_a", "name": "exec", "content": "ok"},
|
||||
]
|
||||
result = AgentRunner._backfill_missing_tool_results(messages)
|
||||
tool_msgs = [m for m in result if m.get("role") == "tool"]
|
||||
assert len(tool_msgs) == 2
|
||||
backfilled = [m for m in tool_msgs if m.get("tool_call_id") == "call_b"]
|
||||
assert len(backfilled) == 1
|
||||
assert backfilled[0]["content"] == _BACKFILL_CONTENT
|
||||
assert backfilled[0]["name"] == "read_file"
|
||||
|
||||
|
||||
def test_drop_orphan_tool_results_removes_unmatched_tool_messages():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_ok", "type": "function", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_ok", "name": "read_file", "content": "ok"},
|
||||
{"role": "tool", "tool_call_id": "call_orphan", "name": "exec", "content": "stale"},
|
||||
{"role": "assistant", "content": "after tool"},
|
||||
]
|
||||
|
||||
cleaned = AgentRunner._drop_orphan_tool_results(messages)
|
||||
|
||||
assert cleaned == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_ok", "type": "function", "function": {"name": "read_file", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_ok", "name": "read_file", "content": "ok"},
|
||||
{"role": "assistant", "content": "after tool"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_noop_when_complete():
|
||||
"""Complete message chains should not be modified."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "call_x", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_x", "name": "exec", "content": "done"},
|
||||
{"role": "assistant", "content": "all good"},
|
||||
]
|
||||
result = AgentRunner._backfill_missing_tool_results(messages)
|
||||
assert result is messages # same object — no copy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_drops_orphan_tool_results_before_model_request():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
captured_messages[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{"role": "tool", "tool_call_id": "call_orphan", "name": "exec", "content": "stale"},
|
||||
{"role": "assistant", "content": "after orphan"},
|
||||
{"role": "user", "content": "new prompt"},
|
||||
],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert all(
|
||||
message.get("tool_call_id") != "call_orphan"
|
||||
for message in captured_messages
|
||||
if message.get("role") == "tool"
|
||||
)
|
||||
assert result.messages[2]["tool_call_id"] == "call_orphan"
|
||||
assert result.final_content == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_repairs_model_context_without_shifting_save_turn_boundary(tmp_path):
|
||||
"""Historical backfill should not duplicate old tail messages on persist."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import _BACKFILL_CONTENT
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
response = LLMResponse(content="new answer", tool_calls=[], usage={})
|
||||
provider.chat_with_retry = AsyncMock(return_value=response)
|
||||
provider.chat_stream_with_retry = AsyncMock(return_value=response)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.messages = [
|
||||
{"role": "user", "content": "old user", "timestamp": "2026-01-01T00:00:00"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_missing",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
"timestamp": "2026-01-01T00:00:01",
|
||||
},
|
||||
{"role": "assistant", "content": "old tail", "timestamp": "2026-01-01T00:00:02"},
|
||||
]
|
||||
loop.sessions.save(session)
|
||||
|
||||
result = await loop._process_message(
|
||||
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="new prompt")
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.content == "new answer"
|
||||
|
||||
request_messages = provider.chat_with_retry.await_args.kwargs["messages"]
|
||||
synthetic = [
|
||||
message
|
||||
for message in request_messages
|
||||
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
|
||||
]
|
||||
assert len(synthetic) == 1
|
||||
assert synthetic[0]["content"] == _BACKFILL_CONTENT
|
||||
|
||||
session_after = loop.sessions.get_or_create("cli:test")
|
||||
assert [
|
||||
{
|
||||
key: value
|
||||
for key, value in message.items()
|
||||
if key in {"role", "content", "tool_call_id", "name", "tool_calls"}
|
||||
}
|
||||
for message in session_after.messages
|
||||
] == [
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_missing",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "old tail"},
|
||||
{"role": "user", "content": "new prompt"},
|
||||
{"role": "assistant", "content": "new answer"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_backfill_only_mutates_model_context_not_returned_messages():
|
||||
"""Runner should repair orphaned tool calls for the model without rewriting result.messages."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _BACKFILL_CONTENT
|
||||
|
||||
provider = MagicMock()
|
||||
captured_messages: list[dict] = []
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
captured_messages[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
initial_messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_missing",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "old tail"},
|
||||
{"role": "user", "content": "new prompt"},
|
||||
]
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=initial_messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
synthetic = [
|
||||
message
|
||||
for message in captured_messages
|
||||
if message.get("role") == "tool" and message.get("tool_call_id") == "call_missing"
|
||||
]
|
||||
assert len(synthetic) == 1
|
||||
assert synthetic[0]["content"] == _BACKFILL_CONTENT
|
||||
|
||||
assert [
|
||||
{
|
||||
key: value
|
||||
for key, value in message.items()
|
||||
if key in {"role", "content", "tool_call_id", "name", "tool_calls"}
|
||||
}
|
||||
for message in result.messages
|
||||
] == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_missing",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "old tail"},
|
||||
{"role": "user", "content": "new prompt"},
|
||||
{"role": "assistant", "content": "done"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Microcompact (stale tool result compaction)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_replaces_old_tool_results():
|
||||
"""Tool results beyond _MICROCOMPACT_KEEP_RECENT should be summarized."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
long_content = "x" * 600
|
||||
messages: list[dict] = [{"role": "system", "content": "sys"}]
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "read_file",
|
||||
"content": long_content,
|
||||
})
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
tool_msgs = [m for m in result if m.get("role") == "tool"]
|
||||
stale_count = total - _MICROCOMPACT_KEEP_RECENT
|
||||
compacted = [m for m in tool_msgs if "omitted from context" in str(m.get("content", ""))]
|
||||
preserved = [m for m in tool_msgs if m.get("content") == long_content]
|
||||
assert len(compacted) == stale_count
|
||||
assert len(preserved) == _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_preserves_short_results():
|
||||
"""Short tool results (< _MICROCOMPACT_MIN_CHARS) should not be replaced."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
messages: list[dict] = []
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "exec",
|
||||
"content": "short",
|
||||
})
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
assert result is messages # no copy needed — all stale results are short
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microcompact_skips_non_compactable_tools():
|
||||
"""Non-compactable tools (e.g. 'message') should never be replaced."""
|
||||
from nanobot.agent.runner import AgentRunner, _MICROCOMPACT_KEEP_RECENT
|
||||
|
||||
total = _MICROCOMPACT_KEEP_RECENT + 5
|
||||
long_content = "y" * 1000
|
||||
messages: list[dict] = []
|
||||
for i in range(total):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": f"c{i}", "type": "function", "function": {"name": "message", "arguments": "{}"}}],
|
||||
})
|
||||
messages.append({
|
||||
"role": "tool", "tool_call_id": f"c{i}", "name": "message",
|
||||
"content": long_content,
|
||||
})
|
||||
|
||||
result = AgentRunner._microcompact(messages)
|
||||
assert result is messages # no compactable tools found
|
||||
|
||||
|
||||
def test_governance_repairs_orphans_after_snip():
|
||||
"""After _snip_history clips an assistant+tool_calls, the second
|
||||
_drop_orphan_tool_results pass must clean up the resulting orphans."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "old msg"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "tc_old", "type": "function",
|
||||
"function": {"name": "search", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "tc_old", "name": "search",
|
||||
"content": "old result"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "new msg"},
|
||||
]
|
||||
|
||||
# Simulate snipping that keeps only the tail: drop the assistant with
|
||||
# tool_calls but keep its tool result (orphan).
|
||||
snipped = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "tool", "tool_call_id": "tc_old", "name": "search",
|
||||
"content": "old result"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "new msg"},
|
||||
]
|
||||
|
||||
cleaned = AgentRunner._drop_orphan_tool_results(snipped)
|
||||
# The orphan tool result should be removed.
|
||||
assert not any(
|
||||
m.get("role") == "tool" and m.get("tool_call_id") == "tc_old"
|
||||
for m in cleaned
|
||||
)
|
||||
|
||||
|
||||
def test_governance_fallback_still_repairs_orphans():
|
||||
"""When full governance fails, the fallback must still run
|
||||
_drop_orphan_tool_results and _backfill_missing_tool_results."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
# Messages with an orphan tool result (no matching assistant tool_call).
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "tool", "tool_call_id": "orphan_tc", "name": "read",
|
||||
"content": "stale"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
|
||||
repaired = AgentRunner._drop_orphan_tool_results(messages)
|
||||
repaired = AgentRunner._backfill_missing_tool_results(repaired)
|
||||
# Orphan tool result should be gone.
|
||||
assert not any(m.get("tool_call_id") == "orphan_tc" for m in repaired)
|
||||
def test_snip_history_preserves_user_message_after_truncation(monkeypatch):
|
||||
"""When _snip_history truncates messages and the only user message ends up
|
||||
outside the kept window, the method must recover the nearest user message
|
||||
so the resulting sequence is valid for providers like GLM (which reject
|
||||
system→assistant with error 1214).
|
||||
|
||||
This reproduces the exact scenario from the bug report:
|
||||
- Normal interaction: user asks, assistant calls tool, tool returns,
|
||||
assistant replies.
|
||||
- Injection adds a phantom user message, triggering more tool calls.
|
||||
- _snip_history activates, keeping only recent assistant/tool pairs.
|
||||
- The injected user message is in the truncated prefix and gets lost.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "assistant", "content": "previous reply"},
|
||||
{"role": "user", "content": ".nanobot的同目录"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "tc_1", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "tool output 1"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "tc_2", "type": "function", "function": {"name": "exec", "arguments": "{}"}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_2", "content": "tool output 2"},
|
||||
]
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
context_window_tokens=2000,
|
||||
context_block_limit=100,
|
||||
)
|
||||
|
||||
# Make estimate_prompt_tokens_chain report above budget so _snip_history activates.
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
|
||||
# Make kept window small: only the last 2 messages fit the budget.
|
||||
token_sizes = {
|
||||
"system": 0,
|
||||
"previous reply": 200,
|
||||
".nanobot的同目录": 80,
|
||||
"tool output 1": 80,
|
||||
"tool output 2": 80,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
lambda msg: token_sizes.get(str(msg.get("content")), 100),
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
|
||||
# The first non-system message MUST be user (not assistant).
|
||||
non_system = [m for m in trimmed if m.get("role") != "system"]
|
||||
assert non_system, "trimmed should contain at least one non-system message"
|
||||
assert non_system[0]["role"] == "user", (
|
||||
f"First non-system message must be 'user', got '{non_system[0]['role']}'. "
|
||||
f"Roles: {[m['role'] for m in trimmed]}"
|
||||
)
|
||||
|
||||
|
||||
def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
|
||||
"""Edge case: if non_system has zero user messages, _snip_history should
|
||||
still return a valid sequence (not crash or produce system→assistant)."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
|
||||
{"role": "assistant", "content": "reply 2"},
|
||||
{"role": "tool", "tool_call_id": "tc_2", "content": "result 2"},
|
||||
]
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=messages,
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
context_window_tokens=2000,
|
||||
context_block_limit=100,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.runner.estimate_prompt_tokens_chain", lambda *_a, **_kw: (500, None))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.runner.estimate_message_tokens",
|
||||
lambda msg: 100,
|
||||
)
|
||||
|
||||
trimmed = runner._snip_history(spec, messages)
|
||||
|
||||
# Should not crash. The result should still be a valid list.
|
||||
assert isinstance(trimmed, list)
|
||||
# Must have at least system.
|
||||
assert any(m.get("role") == "system" for m in trimmed)
|
||||
# The _enforce_role_alternation safety net must be able to fix whatever
|
||||
# _snip_history returns here — verify it produces a valid sequence.
|
||||
from nanobot.providers.base import LLMProvider
|
||||
fixed = LLMProvider._enforce_role_alternation(trimmed)
|
||||
non_system = [m for m in fixed if m["role"] != "system"]
|
||||
if non_system:
|
||||
assert non_system[0]["role"] in ("user", "tool"), (
|
||||
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Tests for AgentRunner hook lifecycle: ordering, streaming deltas,
|
||||
cached-token propagation, and hook context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
call_count = {"n": 0}
|
||||
events: list[tuple] = []
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
)
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
class RecordingHook(AgentHook):
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
events.append(("before_iteration", context.iteration))
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
events.append((
|
||||
"before_execute_tools",
|
||||
context.iteration,
|
||||
[tc.name for tc in context.tool_calls],
|
||||
))
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
events.append((
|
||||
"after_iteration",
|
||||
context.iteration,
|
||||
context.final_content,
|
||||
list(context.tool_results),
|
||||
list(context.tool_events),
|
||||
context.stop_reason,
|
||||
))
|
||||
|
||||
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
||||
events.append(("finalize_content", context.iteration, content))
|
||||
return content.upper() if content else content
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=RecordingHook(),
|
||||
))
|
||||
|
||||
assert result.final_content == "DONE"
|
||||
assert events == [
|
||||
("before_iteration", 0),
|
||||
("before_execute_tools", 0, ["list_dir"]),
|
||||
(
|
||||
"after_iteration",
|
||||
0,
|
||||
None,
|
||||
["tool result"],
|
||||
[{"name": "list_dir", "status": "ok", "detail": "tool result"}],
|
||||
None,
|
||||
),
|
||||
("before_iteration", 1),
|
||||
("finalize_content", 1, "done"),
|
||||
("after_iteration", 1, "DONE", [], [], "completed"),
|
||||
]
|
||||
|
||||
|
||||
@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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
streamed: list[str] = []
|
||||
endings: list[bool] = []
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await on_content_delta("he")
|
||||
await on_content_delta("llo")
|
||||
return LLMResponse(content="hello", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
provider.chat_with_retry = AsyncMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
class StreamingHook(AgentHook):
|
||||
def wants_streaming(self) -> bool:
|
||||
return True
|
||||
|
||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||
streamed.append(delta)
|
||||
|
||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||
endings.append(resuming)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=StreamingHook(),
|
||||
))
|
||||
|
||||
assert result.final_content == "hello"
|
||||
assert streamed == ["he", "llo"]
|
||||
assert endings == [False]
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
captured_usage: list[dict] = []
|
||||
|
||||
class UsageHook(AgentHook):
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
captured_usage.append(dict(context.usage))
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="done",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 200, "completion_tokens": 20, "cached_tokens": 150},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=UsageHook(),
|
||||
))
|
||||
|
||||
assert len(captured_usage) == 1
|
||||
assert captured_usage[0]["cached_tokens"] == 150
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
"""Tests for tool result persistence: large results, pruning, temp files, cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_big", name="list_dir", arguments={"path": "."})],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="x" * 20_000)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
workspace=tmp_path,
|
||||
session_key="test:runner",
|
||||
max_tool_result_chars=2048,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||
assert "[tool output persisted]" in tool_message["content"]
|
||||
assert "tool-results" in tool_message["content"]
|
||||
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
||||
|
||||
|
||||
def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
|
||||
from nanobot.utils.helpers import maybe_persist_tool_result
|
||||
|
||||
root = tmp_path / ".nanobot" / "tool-results"
|
||||
old_bucket = root / "old_session"
|
||||
recent_bucket = root / "recent_session"
|
||||
old_bucket.mkdir(parents=True)
|
||||
recent_bucket.mkdir(parents=True)
|
||||
(old_bucket / "old.txt").write_text("old", encoding="utf-8")
|
||||
(recent_bucket / "recent.txt").write_text("recent", encoding="utf-8")
|
||||
|
||||
stale = time.time() - (8 * 24 * 60 * 60)
|
||||
os.utime(old_bucket, (stale, stale))
|
||||
os.utime(old_bucket / "old.txt", (stale, stale))
|
||||
|
||||
persisted = maybe_persist_tool_result(
|
||||
tmp_path,
|
||||
"current:session",
|
||||
"call_big",
|
||||
"x" * 5000,
|
||||
max_chars=64,
|
||||
)
|
||||
|
||||
assert "[tool output persisted]" in persisted
|
||||
assert not old_bucket.exists()
|
||||
assert recent_bucket.exists()
|
||||
assert (root / "current_session" / "call_big.txt").exists()
|
||||
|
||||
|
||||
def test_persist_tool_result_leaves_no_temp_files(tmp_path):
|
||||
from nanobot.utils.helpers import maybe_persist_tool_result
|
||||
|
||||
root = tmp_path / ".nanobot" / "tool-results"
|
||||
maybe_persist_tool_result(
|
||||
tmp_path,
|
||||
"current:session",
|
||||
"call_big",
|
||||
"x" * 5000,
|
||||
max_chars=64,
|
||||
)
|
||||
|
||||
assert (root / "current_session" / "call_big.txt").exists()
|
||||
assert list((root / "current_session").glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_persist_tool_result_logs_cleanup_failures(monkeypatch, tmp_path):
|
||||
from nanobot.utils.helpers import maybe_persist_tool_result
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.helpers._cleanup_tool_result_buckets",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.helpers.logger.exception",
|
||||
lambda message, *args: warnings.append(message.format(*args)),
|
||||
)
|
||||
|
||||
persisted = maybe_persist_tool_result(
|
||||
tmp_path,
|
||||
"current:session",
|
||||
"call_big",
|
||||
"x" * 5000,
|
||||
max_chars=64,
|
||||
)
|
||||
|
||||
assert "[tool output persisted]" in persisted
|
||||
assert warnings and "Failed to clean stale tool result buckets" in warnings[0]
|
||||
async def test_runner_keeps_going_when_tool_result_persistence_fails():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
with patch("nanobot.agent.runner.maybe_persist_tool_result", side_effect=RuntimeError("disk full")):
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "do task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||
assert tool_message["content"] == "tool result"
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for AgentRunner reasoning extraction and emission.
|
||||
|
||||
Covers the three sources of model reasoning (dedicated ``reasoning_content``,
|
||||
Anthropic ``thinking_blocks``, inline ``<think>``/``<thought>`` tags) plus
|
||||
the streaming interaction: reasoning and answer streams are independent
|
||||
channels, gated by ``context.streamed_reasoning`` rather than
|
||||
``context.streamed_content``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.hook import AgentHook
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
class _RecordingHook(AgentHook):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.emitted: list[str] = []
|
||||
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
if reasoning_content:
|
||||
self.emitted.append(reasoning_content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(
|
||||
content="thinking",
|
||||
tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={"path": "."})],
|
||||
reasoning_content="hidden reasoning",
|
||||
thinking_blocks=[{"type": "thinking", "thinking": "step"}],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
captured_second_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="tool result")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "do task"},
|
||||
],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assistant_messages = [
|
||||
msg for msg in captured_second_call
|
||||
if msg.get("role") == "assistant" and msg.get("tool_calls")
|
||||
]
|
||||
assert len(assistant_messages) == 1
|
||||
assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
|
||||
assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_emits_anthropic_thinking_blocks():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="The answer is 42.",
|
||||
thinking_blocks=[
|
||||
{"type": "thinking", "thinking": "Let me analyze this step by step.", "signature": "sig1"},
|
||||
{"type": "thinking", "thinking": "After careful consideration.", "signature": "sig2"},
|
||||
],
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
hook = _RecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer is 42."
|
||||
assert len(hook.emitted) == 1
|
||||
assert "Let me analyze this" in hook.emitted[0]
|
||||
assert "After careful consideration" in hook.emitted[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="<think>Let me think about this...\nThe answer is 42.</think>The answer is 42.",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
hook = _RecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "what is the answer?"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer is 42."
|
||||
assert len(hook.emitted) == 1
|
||||
assert "Let me think about this" in hook.emitted[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
return LLMResponse(
|
||||
content="<think>inline thinking</think>The answer.",
|
||||
reasoning_content="dedicated reasoning field",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
hook = _RecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer."
|
||||
assert hook.emitted == ["dedicated reasoning field"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
|
||||
if on_content_delta:
|
||||
await on_content_delta("The ")
|
||||
await on_content_delta("answer.")
|
||||
return LLMResponse(
|
||||
content="The answer.",
|
||||
reasoning_content="step-by-step deduction",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
progress_calls: list[str] = []
|
||||
|
||||
async def _progress(content: str, **_kwargs):
|
||||
progress_calls.append(content)
|
||||
|
||||
hook = _RecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
stream_progress_deltas=True,
|
||||
progress_callback=_progress,
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer."
|
||||
assert progress_calls, "answer should have streamed via progress callback"
|
||||
assert hook.emitted == ["step-by-step deduction"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta=None, **kwargs):
|
||||
if on_content_delta:
|
||||
await on_content_delta("<think>working...</think>")
|
||||
await on_content_delta("The answer.")
|
||||
return LLMResponse(
|
||||
content="<think>working...</think>The answer.",
|
||||
tool_calls=[],
|
||||
usage={"prompt_tokens": 5, "completion_tokens": 3},
|
||||
)
|
||||
|
||||
provider.chat_stream_with_retry = chat_stream_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
async def _progress(content: str, **_kwargs):
|
||||
pass
|
||||
|
||||
hook = _RecordingHook()
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "question"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=hook,
|
||||
stream_progress_deltas=True,
|
||||
progress_callback=_progress,
|
||||
))
|
||||
|
||||
assert result.final_content == "The answer."
|
||||
assert hook.emitted == ["working..."]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for AgentRunner security: workspace violations, SSRF, shell guard, throttling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
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_does_not_abort_on_workspace_violation_anymore():
|
||||
"""v2 behavior: workspace-bound rejections are *soft* tool errors.
|
||||
|
||||
Previously (PR #3493) any workspace boundary error became a fatal
|
||||
RuntimeError that aborted the turn. That silently killed legitimate
|
||||
workspace commands once the heuristic guard misfired (#3599 #3605), so
|
||||
we now hand the error back to the LLM as a recoverable tool result and
|
||||
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="trying outside",
|
||||
tool_calls=[ToolCallRequest(
|
||||
id="call_1", name="read_file", arguments={"path": "/tmp/outside.md"},
|
||||
)],
|
||||
),
|
||||
LLMResponse(content="ok, telling the user instead", tool_calls=[]),
|
||||
])
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(
|
||||
side_effect=PermissionError(
|
||||
"Path /tmp/outside.md is outside allowed directory /workspace"
|
||||
)
|
||||
)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 2, (
|
||||
"workspace violation must NOT short-circuit the loop"
|
||||
)
|
||||
assert result.stop_reason != "tool_error"
|
||||
assert result.error is None
|
||||
assert result.final_content == "ok, telling the user instead"
|
||||
assert result.tool_events and result.tool_events[0]["status"] == "error"
|
||||
# Detail still carries the workspace_violation breadcrumb for telemetry,
|
||||
# but the runner did not raise.
|
||||
assert "workspace_violation" in result.tool_events[0]["detail"]
|
||||
|
||||
|
||||
def test_is_ssrf_violation_recognizes_private_url_blocks():
|
||||
"""SSRF rejections are classified separately from workspace boundaries."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
|
||||
assert AgentRunner._is_ssrf_violation(
|
||||
"URL validation failed: Blocked: host resolves to private/internal address 192.168.1.2"
|
||||
) is True
|
||||
|
||||
# Workspace-bound markers are NOT classified as SSRF.
|
||||
assert AgentRunner._is_ssrf_violation(
|
||||
"Error: Command blocked by safety guard (path outside working dir)"
|
||||
) is False
|
||||
assert AgentRunner._is_ssrf_violation(
|
||||
"Path /tmp/x is outside allowed directory /ws"
|
||||
) is False
|
||||
# Deny / allowlist filter messages stay non-fatal too.
|
||||
assert AgentRunner._is_ssrf_violation(
|
||||
"Error: Command blocked by deny pattern filter"
|
||||
) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
|
||||
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="curl-ing metadata",
|
||||
tool_calls=[ToolCallRequest(
|
||||
id="call_ssrf",
|
||||
name="exec",
|
||||
arguments={"command": "curl http://169.254.169.254"},
|
||||
)],
|
||||
),
|
||||
LLMResponse(
|
||||
content="I cannot access that private URL. Please share local files.",
|
||||
tool_calls=[],
|
||||
),
|
||||
])
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value=(
|
||||
"Error: Command blocked by safety guard (internal/private URL detected)"
|
||||
))
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 2
|
||||
assert result.stop_reason == "completed"
|
||||
assert result.error is None
|
||||
assert result.final_content == "I cannot access that private URL. Please share local files."
|
||||
assert result.tool_events and result.tool_events[0]["detail"].startswith("ssrf_violation:")
|
||||
tool_messages = [m for m in result.messages if m.get("role") == "tool"]
|
||||
assert tool_messages
|
||||
assert "non-bypassable security boundary" in tool_messages[0]["content"]
|
||||
assert "Do not retry" in tool_messages[0]["content"]
|
||||
assert "tools.ssrfWhitelist" in tool_messages[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
|
||||
"""Reporter scenario for #3599 / #3605 -- guard hit, agent recovers.
|
||||
|
||||
The shell `_guard_command` heuristic fires on `2>/dev/null`-style
|
||||
redirects and other shell idioms. Before v2 that abort'd the whole
|
||||
turn (silent hang on Telegram per #3605); now the LLM gets the soft
|
||||
error back and can finalize on the next iteration.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_second_call: list[dict] = []
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
if provider.chat_with_retry.await_count == 1:
|
||||
return LLMResponse(
|
||||
content="trying noisy cleanup",
|
||||
tool_calls=[ToolCallRequest(
|
||||
id="call_blocked",
|
||||
name="exec",
|
||||
arguments={"command": "rm scratch.txt 2>/dev/null"},
|
||||
)],
|
||||
)
|
||||
captured_second_call[:] = list(messages)
|
||||
return LLMResponse(content="recovered final answer", tool_calls=[])
|
||||
|
||||
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(
|
||||
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||
)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=3,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert provider.chat_with_retry.await_count == 2, (
|
||||
"guard hit must NOT short-circuit the loop -- LLM should get a second turn"
|
||||
)
|
||||
assert result.stop_reason != "tool_error"
|
||||
assert result.error is None
|
||||
assert result.final_content == "recovered final answer"
|
||||
assert result.tool_events and result.tool_events[0]["status"] == "error"
|
||||
# v2: detail keeps the breadcrumb but the runner did not raise.
|
||||
assert "workspace_violation" in result.tool_events[0]["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_throttles_repeated_workspace_bypass_attempts():
|
||||
"""#3493 motivation: stop the LLM bypass loop without aborting the turn.
|
||||
|
||||
LLM keeps switching tools (read_file -> exec cat -> python -c open(...))
|
||||
against the same outside path. After the soft retry budget is exhausted
|
||||
the runner replaces the tool result with a hard "stop trying" message
|
||||
so the model finally gives up and surfaces the boundary to the user.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
bypass_attempts = [
|
||||
ToolCallRequest(
|
||||
id=f"a{i}", name="exec",
|
||||
arguments={"command": f"cat /Users/x/Downloads/01.md # try {i}"},
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
responses: list[LLMResponse] = [
|
||||
LLMResponse(content=f"try {i}", tool_calls=[bypass_attempts[i]])
|
||||
for i in range(4)
|
||||
]
|
||||
responses.append(LLMResponse(content="ok telling user", tool_calls=[]))
|
||||
|
||||
provider = MagicMock()
|
||||
provider.chat_with_retry = AsyncMock(side_effect=responses)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(
|
||||
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||
)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=10,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
# All 4 bypass attempts surface to the LLM (no fatal abort), and the
|
||||
# runner finally completes once the LLM stops asking.
|
||||
assert result.stop_reason != "tool_error"
|
||||
assert result.error is None
|
||||
assert result.final_content == "ok telling user"
|
||||
# The third+ attempts must have been escalated -- look at the events.
|
||||
escalated = [
|
||||
ev for ev in result.tool_events
|
||||
if ev["status"] == "error"
|
||||
and ev["detail"].startswith("workspace_violation_escalated:")
|
||||
]
|
||||
assert escalated, (
|
||||
"expected at least one escalated workspace_violation event, got: "
|
||||
f"{result.tool_events}"
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for AgentRunner tool execution: batching, concurrency, exclusive tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
class _DelayTool(Tool):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
delay: float,
|
||||
read_only: bool,
|
||||
shared_events: list[str],
|
||||
exclusive: bool = False,
|
||||
):
|
||||
self._name = name
|
||||
self._delay = delay
|
||||
self._read_only = read_only
|
||||
self._shared_events = shared_events
|
||||
self._exclusive = exclusive
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict:
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self._read_only
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
return self._exclusive
|
||||
|
||||
async def execute(self, **kwargs):
|
||||
self._shared_events.append(f"start:{self._name}")
|
||||
await asyncio.sleep(self._delay)
|
||||
self._shared_events.append(f"end:{self._name}")
|
||||
return self._name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_batches_read_only_tools_before_exclusive_work():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
tools = ToolRegistry()
|
||||
shared_events: list[str] = []
|
||||
read_a = _DelayTool("read_a", delay=0.05, read_only=True, shared_events=shared_events)
|
||||
read_b = _DelayTool("read_b", delay=0.05, read_only=True, shared_events=shared_events)
|
||||
write_a = _DelayTool("write_a", delay=0.01, read_only=False, shared_events=shared_events)
|
||||
tools.register(read_a)
|
||||
tools.register(read_b)
|
||||
tools.register(write_a)
|
||||
|
||||
runner = AgentRunner(MagicMock())
|
||||
await runner._execute_tools(
|
||||
AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
concurrent_tools=True,
|
||||
),
|
||||
[
|
||||
ToolCallRequest(id="ro1", name="read_a", arguments={}),
|
||||
ToolCallRequest(id="ro2", name="read_b", arguments={}),
|
||||
ToolCallRequest(id="rw1", name="write_a", arguments={}),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
assert shared_events[0:2] == ["start:read_a", "start:read_b"]
|
||||
assert "end:read_a" in shared_events and "end:read_b" in shared_events
|
||||
assert shared_events.index("end:read_a") < shared_events.index("start:write_a")
|
||||
assert shared_events.index("end:read_b") < shared_events.index("start:write_a")
|
||||
assert shared_events[-2:] == ["start:write_a", "end:write_a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_does_not_batch_exclusive_read_only_tools():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
tools = ToolRegistry()
|
||||
shared_events: list[str] = []
|
||||
read_a = _DelayTool("read_a", delay=0.03, read_only=True, shared_events=shared_events)
|
||||
read_b = _DelayTool("read_b", delay=0.03, read_only=True, shared_events=shared_events)
|
||||
ddg_like = _DelayTool(
|
||||
"ddg_like",
|
||||
delay=0.01,
|
||||
read_only=True,
|
||||
shared_events=shared_events,
|
||||
exclusive=True,
|
||||
)
|
||||
tools.register(read_a)
|
||||
tools.register(ddg_like)
|
||||
tools.register(read_b)
|
||||
|
||||
runner = AgentRunner(MagicMock())
|
||||
await runner._execute_tools(
|
||||
AgentRunSpec(
|
||||
initial_messages=[],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
concurrent_tools=True,
|
||||
),
|
||||
[
|
||||
ToolCallRequest(id="ro1", name="read_a", arguments={}),
|
||||
ToolCallRequest(id="ddg1", name="ddg_like", arguments={}),
|
||||
ToolCallRequest(id="ro2", name="read_b", arguments={}),
|
||||
],
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
assert shared_events[0] == "start:read_a"
|
||||
assert shared_events.index("end:read_a") < shared_events.index("start:ddg_like")
|
||||
assert shared_events.index("end:ddg_like") < shared_events.index("start:read_b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_blocks_repeated_external_fetches():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
|
||||
provider = MagicMock()
|
||||
captured_final_call: list[dict] = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] <= 3:
|
||||
return LLMResponse(
|
||||
content="working",
|
||||
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="web_fetch", arguments={"url": "https://example.com"})],
|
||||
usage={},
|
||||
)
|
||||
captured_final_call[:] = messages
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
tools.execute = AsyncMock(return_value="page content")
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "research task"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=4,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
))
|
||||
|
||||
assert result.final_content == "done"
|
||||
assert tools.execute.await_count == 2
|
||||
blocked_tool_message = [
|
||||
msg for msg in captured_final_call
|
||||
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
||||
][0]
|
||||
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
||||
@@ -0,0 +1,294 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
|
||||
|
||||
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=max_tokens, temperature=0.1, reasoning_effort=None
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _make_loop(tmp_path, presets=None, active_preset=None):
|
||||
provider = _provider("base-model")
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets=presets or {},
|
||||
model_preset=active_preset,
|
||||
)
|
||||
|
||||
|
||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.model_preset is None
|
||||
|
||||
|
||||
def test_model_preset_setter_updates_state(tmp_path) -> None:
|
||||
presets = {
|
||||
"fast": ModelPresetConfig(
|
||||
model="openai/gpt-4.1",
|
||||
provider="openai",
|
||||
max_tokens=4096,
|
||||
context_window_tokens=32_768,
|
||||
temperature=0.5,
|
||||
reasoning_effort="low",
|
||||
)
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
loop.model_preset = "fast"
|
||||
|
||||
assert loop.model_preset == "fast"
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.context_window_tokens == 32_768
|
||||
assert loop.provider.generation.temperature == 0.5
|
||||
assert loop.provider.generation.max_tokens == 4096
|
||||
assert loop.provider.generation.reasoning_effort == "low"
|
||||
assert loop.subagents.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.context_window_tokens == 32_768
|
||||
assert loop.consolidator.max_completion_tokens == 4096
|
||||
assert loop.dream.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_model_preset_setter_calls_runtime_model_publisher(tmp_path) -> None:
|
||||
published: list[tuple[str, str | None]] = []
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider("base-model", max_tokens=123),
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={"fast": ModelPresetConfig(model="openai/gpt-4.1")},
|
||||
runtime_model_publisher=lambda model, preset: published.append((model, preset)),
|
||||
)
|
||||
|
||||
loop.set_model_preset("fast")
|
||||
|
||||
assert published == [("openai/gpt-4.1", "fast")]
|
||||
|
||||
|
||||
def test_model_preset_setter_replaces_provider_from_snapshot(tmp_path) -> None:
|
||||
old_provider = _provider("base-model", max_tokens=123)
|
||||
new_provider = _provider("anthropic/claude-opus-4-5", max_tokens=2048)
|
||||
preset = ModelPresetConfig(
|
||||
model="anthropic/claude-opus-4-5",
|
||||
provider="anthropic",
|
||||
max_tokens=2048,
|
||||
context_window_tokens=200_000,
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=old_provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={"deep": preset},
|
||||
preset_snapshot_loader=lambda name: ProviderSnapshot(
|
||||
provider=new_provider,
|
||||
model=preset.model,
|
||||
context_window_tokens=preset.context_window_tokens,
|
||||
signature=(name, preset.model),
|
||||
),
|
||||
)
|
||||
|
||||
loop.set_model_preset("deep")
|
||||
|
||||
assert loop.provider is new_provider
|
||||
assert loop.runner.provider is new_provider
|
||||
assert loop.subagents.provider is new_provider
|
||||
assert loop.subagents.runner.provider is new_provider
|
||||
assert loop.consolidator.provider is new_provider
|
||||
assert loop.dream.provider is new_provider
|
||||
assert loop.dream._runner.provider is new_provider
|
||||
assert loop.model == "anthropic/claude-opus-4-5"
|
||||
assert loop.context_window_tokens == 200_000
|
||||
assert loop.consolidator.max_completion_tokens == 2048
|
||||
|
||||
|
||||
def test_model_preset_setter_failure_leaves_old_state(tmp_path) -> None:
|
||||
preset = ModelPresetConfig(model="openai/gpt-4.1", max_tokens=4096)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider("base-model", max_tokens=123),
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={"fast": preset},
|
||||
preset_snapshot_loader=lambda _name: (_ for _ in ()).throw(
|
||||
RuntimeError("provider unavailable")
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="provider unavailable"):
|
||||
loop.set_model_preset("fast")
|
||||
|
||||
assert loop.model_preset is None
|
||||
assert loop.model == "base-model"
|
||||
assert loop.subagents.model == "base-model"
|
||||
assert loop.consolidator.model == "base-model"
|
||||
assert loop.dream.model == "base-model"
|
||||
assert loop.context_window_tokens == 1000
|
||||
assert loop.consolidator.max_completion_tokens == 123
|
||||
|
||||
|
||||
def test_active_model_preset_survives_unchanged_config_refresh(tmp_path) -> None:
|
||||
base_provider = _provider("base-model", max_tokens=123)
|
||||
fast_provider = _provider("openai/gpt-4.1", max_tokens=4096)
|
||||
default_snapshot = ProviderSnapshot(
|
||||
provider=base_provider,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
signature=("base-model", "auto", "openai", "sk-old"),
|
||||
)
|
||||
fast_snapshot = ProviderSnapshot(
|
||||
provider=fast_provider,
|
||||
model="openai/gpt-4.1",
|
||||
context_window_tokens=32_768,
|
||||
signature=("openai/gpt-4.1", "auto", "openai", "sk-old"),
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=base_provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
provider_signature=default_snapshot.signature,
|
||||
model_presets={"fast": ModelPresetConfig(model="openai/gpt-4.1")},
|
||||
provider_snapshot_loader=lambda: default_snapshot,
|
||||
preset_snapshot_loader=lambda _name: fast_snapshot,
|
||||
)
|
||||
|
||||
loop.set_model_preset("fast")
|
||||
loop._refresh_provider_snapshot()
|
||||
|
||||
assert loop.model_preset == "fast"
|
||||
assert loop.provider is fast_provider
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_config_model_refresh_clears_active_model_preset(tmp_path) -> None:
|
||||
base_provider = _provider("base-model", max_tokens=123)
|
||||
fast_provider = _provider("openai/gpt-4.1", max_tokens=4096)
|
||||
webui_provider = _provider("anthropic/claude-opus-4-5", max_tokens=2048)
|
||||
webui_snapshot = ProviderSnapshot(
|
||||
provider=webui_provider,
|
||||
model="anthropic/claude-opus-4-5",
|
||||
context_window_tokens=200_000,
|
||||
signature=("anthropic/claude-opus-4-5", "anthropic", "anthropic", "sk-old"),
|
||||
)
|
||||
fast_snapshot = ProviderSnapshot(
|
||||
provider=fast_provider,
|
||||
model="openai/gpt-4.1",
|
||||
context_window_tokens=32_768,
|
||||
signature=("openai/gpt-4.1", "auto", "openai", "sk-old"),
|
||||
)
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=base_provider,
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
provider_snapshot_loader=lambda: webui_snapshot,
|
||||
provider_signature=("base-model", "auto", "openai", "sk-old"),
|
||||
model_presets={"fast": ModelPresetConfig(model="openai/gpt-4.1")},
|
||||
preset_snapshot_loader=lambda _name: fast_snapshot,
|
||||
)
|
||||
|
||||
loop.set_model_preset("fast")
|
||||
loop._refresh_provider_snapshot()
|
||||
|
||||
assert loop.model_preset is None
|
||||
assert loop.provider is webui_provider
|
||||
assert loop.model == "anthropic/claude-opus-4-5"
|
||||
assert loop.context_window_tokens == 200_000
|
||||
|
||||
|
||||
def test_model_preset_setter_raises_on_unknown(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
with pytest.raises(KeyError, match="model_preset 'missing' not found"):
|
||||
loop.model_preset = "missing"
|
||||
|
||||
|
||||
def test_model_preset_setter_raises_on_empty_string(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
with pytest.raises(ValueError, match="model_preset must be a non-empty string"):
|
||||
loop.model_preset = ""
|
||||
|
||||
|
||||
def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
||||
presets = {
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
output = tool._inspect_all()
|
||||
assert "model_preset: 'fast'" in output
|
||||
|
||||
|
||||
def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
||||
presets = {
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
result = tool._modify("model_preset", "fast")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset == "fast"
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
||||
presets = {
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||
assert "Error" not in result
|
||||
assert loop._active_preset is None
|
||||
assert loop.model == "anthropic/claude-opus-4-5"
|
||||
|
||||
|
||||
def test_from_config_injects_default_preset(tmp_path) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
config = Config.model_validate({
|
||||
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
|
||||
})
|
||||
fake_provider = _provider("openai/gpt-4.1")
|
||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||
loop = AgentLoop.from_config(config)
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.model_preset is None
|
||||
assert "default" in loop.model_presets
|
||||
assert loop.model_presets["default"].model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
config = Config.model_validate({
|
||||
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
|
||||
"model_presets": {"fast": {"model": "openai/gpt-4.1-mini"}},
|
||||
})
|
||||
fake_provider = _provider("openai/gpt-4.1")
|
||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||
loop = AgentLoop.from_config(config)
|
||||
assert loop._provider_snapshot_loader is None
|
||||
assert loop._preset_snapshot_loader is not None
|
||||
@@ -10,6 +10,7 @@ See: https://github.com/HKUDS/nanobot/issues/2966
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
@@ -17,42 +18,47 @@ from unittest.mock import MagicMock, patch, AsyncMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_loop():
|
||||
"""Create a minimal AgentLoop with mocked dependencies."""
|
||||
with patch.object(AgentLoop, "__init__", lambda self: None):
|
||||
loop = AgentLoop()
|
||||
loop.sessions = MagicMock()
|
||||
loop._pending_queues = {}
|
||||
loop._session_locks = {}
|
||||
loop._active_tasks = {}
|
||||
loop._concurrency_gate = None
|
||||
loop._RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||
loop._PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||
loop.bus = MagicMock()
|
||||
loop.bus.publish_outbound = AsyncMock()
|
||||
loop.bus.publish_inbound = AsyncMock()
|
||||
loop.commands = MagicMock()
|
||||
loop.commands.dispatch_priority = AsyncMock(return_value=None)
|
||||
return loop
|
||||
def _make_provider():
|
||||
"""Create an LLM provider mock with required attributes."""
|
||||
from types import SimpleNamespace
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = SimpleNamespace(max_tokens=4096, temperature=0.1, reasoning_effort=None)
|
||||
provider.estimate_prompt_tokens.return_value = (10_000, "test")
|
||||
return provider
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
"""Create a real AgentLoop with mocked provider — avoids patching __init__."""
|
||||
bus = MessageBus()
|
||||
provider = _make_provider()
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
return AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
|
||||
|
||||
class TestStopPreservesContext:
|
||||
"""Verify that /stop restores partial context via checkpoint."""
|
||||
|
||||
def test_restore_checkpoint_method_exists(self, mock_loop):
|
||||
def test_restore_checkpoint_method_exists(self, tmp_path):
|
||||
"""AgentLoop should have _restore_runtime_checkpoint."""
|
||||
assert hasattr(mock_loop, "_restore_runtime_checkpoint")
|
||||
loop = _make_loop(tmp_path)
|
||||
assert hasattr(loop, "_restore_runtime_checkpoint")
|
||||
|
||||
def test_checkpoint_key_constant(self, mock_loop):
|
||||
def test_checkpoint_key_constant(self, tmp_path):
|
||||
"""The runtime checkpoint key should be defined."""
|
||||
assert mock_loop._RUNTIME_CHECKPOINT_KEY == "runtime_checkpoint"
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop._RUNTIME_CHECKPOINT_KEY == "runtime_checkpoint"
|
||||
|
||||
def test_cancel_dispatch_restores_checkpoint(self, mock_loop):
|
||||
def test_cancel_dispatch_restores_checkpoint(self, tmp_path):
|
||||
"""When a task is cancelled, the checkpoint should be restored."""
|
||||
# Create a mock session with a checkpoint
|
||||
loop = _make_loop(tmp_path)
|
||||
session = MagicMock()
|
||||
session.metadata = {
|
||||
"runtime_checkpoint": {
|
||||
@@ -74,14 +80,11 @@ class TestStopPreservesContext:
|
||||
session.messages = [
|
||||
{"role": "user", "content": "Search for something"},
|
||||
]
|
||||
mock_loop.sessions.get_or_create.return_value = session
|
||||
loop.sessions.get_or_create.return_value = session
|
||||
|
||||
# The restore method should add checkpoint messages to session history
|
||||
restored = mock_loop._restore_runtime_checkpoint(session)
|
||||
restored = loop._restore_runtime_checkpoint(session)
|
||||
assert restored is True
|
||||
# After restore, session should have more messages
|
||||
assert len(session.messages) > 1
|
||||
# The checkpoint should be cleared
|
||||
assert "runtime_checkpoint" not in session.metadata
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Tests for SubagentManager."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_uses_tool_loader():
|
||||
"""Verify subagent registers tools via ToolLoader, not hard-coded imports."""
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
sm = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=Path("/tmp"),
|
||||
bus=MessageBus(),
|
||||
model="test",
|
||||
max_tool_result_chars=16_000,
|
||||
)
|
||||
tools = sm._build_tools()
|
||||
assert tools.has("read_file")
|
||||
assert tools.has("write_file")
|
||||
assert tools.has("glob")
|
||||
assert not tools.has("message")
|
||||
assert not tools.has("spawn")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_build_tools_isolates_file_read_state(tmp_path):
|
||||
"""Each spawned subagent needs a fresh file-state cache."""
|
||||
(tmp_path / "note.txt").write_text("hello\n", encoding="utf-8")
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
sm = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
model="test",
|
||||
max_tool_result_chars=16_000,
|
||||
)
|
||||
|
||||
first_read = sm._build_tools().get("read_file")
|
||||
second_read = sm._build_tools().get("read_file")
|
||||
|
||||
assert first_read is not second_read
|
||||
assert (await first_read.execute(path="note.txt")).startswith("1| hello")
|
||||
second_result = await second_read.execute(path="note.txt")
|
||||
assert second_result.startswith("1| hello")
|
||||
assert "File unchanged" not in second_result
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Tests for SubagentManager lifecycle — spawn, run, announce, cancel."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.hook import AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.subagent import (
|
||||
SubagentManager,
|
||||
SubagentStatus,
|
||||
_SubagentHook,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _manager(tmp_path: Path, **kw) -> SubagentManager:
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
defaults = dict(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
model="test-model",
|
||||
max_tool_result_chars=16_000,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return SubagentManager(**defaults)
|
||||
|
||||
|
||||
def _make_hook_context(**overrides) -> AgentHookContext:
|
||||
defaults = dict(
|
||||
iteration=1,
|
||||
tool_calls=[],
|
||||
tool_events=[],
|
||||
messages=[],
|
||||
usage={},
|
||||
error=None,
|
||||
stop_reason="completed",
|
||||
final_content="ok",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return AgentHookContext(**defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentStatus defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubagentStatus:
|
||||
def test_defaults(self):
|
||||
s = SubagentStatus(
|
||||
task_id="abc", label="test", task_description="do stuff",
|
||||
started_at=time.monotonic(),
|
||||
)
|
||||
assert s.phase == "initializing"
|
||||
assert s.iteration == 0
|
||||
assert s.tool_events == []
|
||||
assert s.usage == {}
|
||||
assert s.stop_reason is None
|
||||
assert s.error is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetProvider:
|
||||
def test_updates_provider_model_runner(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
new_provider = MagicMock(spec=LLMProvider)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpawn:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_string_with_task_id(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
result = await sm.spawn("do something")
|
||||
assert "started" in result
|
||||
assert "id:" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_task_in_running_tasks(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("task", session_key="s1")
|
||||
assert len(sm._running_tasks) == 1
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
assert len(sm._running_tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_status(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("my task")
|
||||
await asyncio.sleep(0.1)
|
||||
# Status cleaned up after task completes
|
||||
assert len(sm._task_statuses) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_in_session_tasks(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("task", session_key="s1")
|
||||
assert "s1" in sm._session_tasks
|
||||
assert len(sm._session_tasks["s1"]) == 1
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
assert "s1" not in sm._session_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_session_key_no_registration(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("task")
|
||||
assert len(sm._session_tasks) == 0
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_defaults_to_truncated_task(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
long_task = "A" * 50
|
||||
await sm.spawn(long_task, session_key="s1")
|
||||
status = next(iter(sm._task_statuses.values()))
|
||||
assert status.label == long_task[:30] + "..."
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_label(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("task", label="Custom Label", session_key="s1")
|
||||
status = next(iter(sm._task_statuses.values()))
|
||||
assert status.label == "Custom Label"
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_callback_removes_all_entries(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("task", session_key="s1")
|
||||
await asyncio.sleep(0.1)
|
||||
assert len(sm._running_tasks) == 0
|
||||
assert len(sm._task_statuses) == 0
|
||||
assert len(sm._session_tasks) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_subagent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunSubagent:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_run(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="Task done!", messages=[], stop_reason="completed",
|
||||
))
|
||||
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
||||
await sm._run_subagent(
|
||||
"t1", "do task", "label",
|
||||
{"channel": "cli", "chat_id": "direct"},
|
||||
SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic()),
|
||||
)
|
||||
mock_announce.assert_called_once()
|
||||
assert mock_announce.call_args.args[-2] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_error_run(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content=None, messages=[], stop_reason="tool_error",
|
||||
tool_events=[{"name": "read_file", "status": "error", "detail": "not found"}],
|
||||
))
|
||||
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
||||
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
||||
await sm._run_subagent(
|
||||
"t1", "do task", "label",
|
||||
{"channel": "cli", "chat_id": "direct"}, status,
|
||||
)
|
||||
assert mock_announce.call_args.args[-2] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_run(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
||||
with patch.object(sm, "_announce_result", new_callable=AsyncMock) as mock_announce:
|
||||
await sm._run_subagent(
|
||||
"t1", "do task", "label",
|
||||
{"channel": "cli", "chat_id": "direct"}, status,
|
||||
)
|
||||
assert status.phase == "error"
|
||||
assert "LLM down" in status.error
|
||||
assert mock_announce.call_args.args[-2] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_updated_on_success(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="ok", messages=[], stop_reason="completed",
|
||||
))
|
||||
status = SubagentStatus(task_id="t1", label="label", task_description="do task", started_at=time.monotonic())
|
||||
with patch.object(sm, "_announce_result", new_callable=AsyncMock):
|
||||
await sm._run_subagent(
|
||||
"t1", "do task", "label",
|
||||
{"channel": "cli", "chat_id": "direct"}, status,
|
||||
)
|
||||
assert status.phase == "done"
|
||||
assert status.stop_reason == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _announce_result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnnounceResult:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publishes_inbound_message(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result text",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
|
||||
assert len(published) == 1
|
||||
msg = published[0]
|
||||
assert msg.channel == "system"
|
||||
assert msg.sender_id == "subagent"
|
||||
assert msg.metadata["injected_event"] == "subagent_result"
|
||||
assert msg.metadata["subagent_task_id"] == "t1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_override(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "telegram", "chat_id": "123", "session_key": "s1"}, "ok",
|
||||
)
|
||||
|
||||
assert published[0].session_key_override == "s1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_override_fallback(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "telegram", "chat_id": "123"}, "ok",
|
||||
)
|
||||
|
||||
assert published[0].session_key_override == "telegram:123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ok_status_text(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
)
|
||||
|
||||
assert "completed successfully" in published[0].content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status_text(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "error details",
|
||||
{"channel": "cli", "chat_id": "direct"}, "error",
|
||||
)
|
||||
|
||||
assert "failed" in published[0].content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_origin_message_id_in_metadata(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
published = []
|
||||
sm.bus.publish_inbound = AsyncMock(side_effect=lambda msg: published.append(msg))
|
||||
|
||||
await sm._announce_result(
|
||||
"t1", "label", "task", "result",
|
||||
{"channel": "cli", "chat_id": "direct"}, "ok",
|
||||
origin_message_id="msg-123",
|
||||
)
|
||||
|
||||
assert published[0].metadata["origin_message_id"] == "msg-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_partial_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatPartialProgress:
|
||||
def _make_result(self, tool_events=None, error=None):
|
||||
return MagicMock(tool_events=tool_events or [], error=error)
|
||||
|
||||
def test_completed_only(self):
|
||||
result = self._make_result(tool_events=[
|
||||
{"name": "read_file", "status": "ok", "detail": "file content"},
|
||||
{"name": "exec", "status": "ok", "detail": "output"},
|
||||
])
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Completed steps:" in text
|
||||
assert "read_file" in text
|
||||
assert "exec" in text
|
||||
|
||||
def test_failure_only(self):
|
||||
result = self._make_result(tool_events=[
|
||||
{"name": "read_file", "status": "error", "detail": "not found"},
|
||||
])
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Failure:" in text
|
||||
assert "not found" in text
|
||||
|
||||
def test_completed_and_failure(self):
|
||||
result = self._make_result(tool_events=[
|
||||
{"name": "read_file", "status": "ok", "detail": "content"},
|
||||
{"name": "exec", "status": "error", "detail": "timeout"},
|
||||
])
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Completed steps:" in text
|
||||
assert "Failure:" in text
|
||||
|
||||
def test_limited_to_last_three(self):
|
||||
result = self._make_result(tool_events=[
|
||||
{"name": f"tool_{i}", "status": "ok", "detail": f"result_{i}"}
|
||||
for i in range(5)
|
||||
])
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "tool_2" in text
|
||||
assert "tool_3" in text
|
||||
assert "tool_4" in text
|
||||
assert "tool_0" not in text
|
||||
assert "tool_1" not in text
|
||||
|
||||
def test_error_without_failure_event(self):
|
||||
result = self._make_result(
|
||||
tool_events=[{"name": "read_file", "status": "ok", "detail": "ok"}],
|
||||
error="Something went wrong",
|
||||
)
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Something went wrong" in text
|
||||
|
||||
def test_empty_events_with_error(self):
|
||||
result = self._make_result(error="Total failure")
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Total failure" in text
|
||||
|
||||
def test_empty_no_error_returns_fallback(self):
|
||||
result = self._make_result()
|
||||
text = SubagentManager._format_partial_progress(result)
|
||||
assert "Error" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cancel_by_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCancelBySession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancels_running_tasks(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("task1", session_key="s1")
|
||||
await sm.spawn("task2", session_key="s1")
|
||||
assert len(sm._session_tasks.get("s1", set())) == 2
|
||||
|
||||
count = await sm.cancel_by_session("s1")
|
||||
assert count == 2
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tasks_returns_zero(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
count = await sm.cancel_by_session("nonexistent")
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_done_not_counted(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
sm.runner.run = AsyncMock(return_value=AgentRunResult(
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("task1", session_key="s1")
|
||||
await asyncio.sleep(0.1) # Wait for completion
|
||||
|
||||
count = await sm.cancel_by_session("s1")
|
||||
assert count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_running_count / get_running_count_by_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunningCounts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_count_zero(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
assert sm.get_running_count() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_count_tracks_tasks(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
block = asyncio.Event()
|
||||
async def _slow_run(spec):
|
||||
await block.wait()
|
||||
return AgentRunResult(final_content="done", messages=[], stop_reason="completed")
|
||||
sm.runner.run = _slow_run
|
||||
|
||||
await sm.spawn("t1", session_key="s1")
|
||||
await sm.spawn("t2", session_key="s1")
|
||||
assert sm.get_running_count() == 2
|
||||
assert sm.get_running_count_by_session("s1") == 2
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
assert sm.get_running_count() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_count_by_session_nonexistent(self, tmp_path):
|
||||
sm = _manager(tmp_path)
|
||||
assert sm.get_running_count_by_session("nonexistent") == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _SubagentHook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSubagentHook:
|
||||
@pytest.mark.asyncio
|
||||
async def test_before_execute_tools_logs(self, tmp_path):
|
||||
hook = _SubagentHook("t1")
|
||||
tool_call = MagicMock()
|
||||
tool_call.name = "read_file"
|
||||
tool_call.arguments = {"path": "/tmp/test"}
|
||||
ctx = _make_hook_context(tool_calls=[tool_call])
|
||||
# Should not raise
|
||||
await hook.before_execute_tools(ctx)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_updates_status(self):
|
||||
status = SubagentStatus(
|
||||
task_id="t1", label="test", task_description="do", started_at=time.monotonic(),
|
||||
)
|
||||
hook = _SubagentHook("t1", status)
|
||||
ctx = _make_hook_context(
|
||||
iteration=3,
|
||||
tool_events=[{"name": "read_file", "status": "ok", "detail": ""}],
|
||||
usage={"prompt_tokens": 100},
|
||||
)
|
||||
await hook.after_iteration(ctx)
|
||||
assert status.iteration == 3
|
||||
assert len(status.tool_events) == 1
|
||||
assert status.usage == {"prompt_tokens": 100}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_no_status_noop(self):
|
||||
hook = _SubagentHook("t1", status=None)
|
||||
ctx = _make_hook_context(iteration=5)
|
||||
# Should not raise
|
||||
await hook.after_iteration(ctx)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_sets_error(self):
|
||||
status = SubagentStatus(
|
||||
task_id="t1", label="test", task_description="do", started_at=time.monotonic(),
|
||||
)
|
||||
hook = _SubagentHook("t1", status)
|
||||
ctx = _make_hook_context(error="something broke")
|
||||
await hook.after_iteration(ctx)
|
||||
assert status.error == "something broke"
|
||||
@@ -14,7 +14,7 @@ from nanobot.config.schema import AgentDefaults
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
|
||||
def _make_loop(*, exec_config=None):
|
||||
def _make_loop(*, tools_config=None):
|
||||
"""Create a minimal AgentLoop with mocked dependencies."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -29,7 +29,7 @@ def _make_loop(*, exec_config=None):
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, exec_config=exec_config)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, tools_config=tools_config)
|
||||
return loop, bus
|
||||
|
||||
|
||||
@@ -103,9 +103,10 @@ class TestHandleStop:
|
||||
|
||||
class TestDispatch:
|
||||
def test_exec_tool_not_registered_when_disabled(self):
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
|
||||
loop, _bus = _make_loop(exec_config=ExecToolConfig(enable=False))
|
||||
loop, _bus = _make_loop(tools_config=ToolsConfig(exec=ExecToolConfig(enable=False)))
|
||||
|
||||
assert loop.tools.get("exec") is None
|
||||
|
||||
@@ -286,7 +287,8 @@ class TestSubagentCancellation:
|
||||
async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path):
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
@@ -296,7 +298,7 @@ class TestSubagentCancellation:
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
exec_config=ExecToolConfig(enable=False),
|
||||
tools_config=ToolsConfig(exec=ExecToolConfig(enable=False)),
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
|
||||
|
||||
def test_loader_discovers_entry_point_tools():
|
||||
"""Simulate an entry-point plugin being discovered."""
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "my_plugin"
|
||||
|
||||
class _FakeTool(Tool):
|
||||
__name__ = "FakeTool"
|
||||
_plugin_discoverable = True
|
||||
_scopes = {"core"}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fake_tool"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A fake tool for testing."
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict:
|
||||
return {"type": "object"}
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx):
|
||||
return MagicMock()
|
||||
|
||||
async def execute(self, **_):
|
||||
return "ok"
|
||||
|
||||
mock_ep.load.return_value = _FakeTool
|
||||
|
||||
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
|
||||
loader = ToolLoader()
|
||||
discovered = loader._discover_plugins()
|
||||
|
||||
assert "my_plugin" in discovered
|
||||
assert discovered["my_plugin"] is _FakeTool
|
||||
|
||||
|
||||
def test_loader_skips_abstract_entry_point_tools():
|
||||
"""Verify abstract tool classes registered via entry_points are skipped."""
|
||||
mock_ep = MagicMock()
|
||||
mock_ep.name = "abstract_plugin"
|
||||
|
||||
class _AbstractTool(Tool):
|
||||
__name__ = "AbstractTool"
|
||||
_plugin_discoverable = True
|
||||
_scopes = {"core"}
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def create(cls, ctx):
|
||||
return MagicMock()
|
||||
|
||||
# Intentionally missing abstract properties (name, description, parameters, execute)
|
||||
|
||||
mock_ep.load.return_value = _AbstractTool
|
||||
|
||||
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
|
||||
loader = ToolLoader()
|
||||
discovered = loader._discover_plugins()
|
||||
|
||||
assert "abstract_plugin" not in discovered
|
||||
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
|
||||
|
||||
class _CoreOnlyTool(Tool):
|
||||
_scopes = {"core"}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "core_only"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return "..."
|
||||
|
||||
@property
|
||||
def parameters(self):
|
||||
return {"type": "object"}
|
||||
|
||||
async def execute(self, **_):
|
||||
return "ok"
|
||||
|
||||
|
||||
class _SubagentOnlyTool(Tool):
|
||||
_scopes = {"subagent"}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "sub_only"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return "..."
|
||||
|
||||
@property
|
||||
def parameters(self):
|
||||
return {"type": "object"}
|
||||
|
||||
async def execute(self, **_):
|
||||
return "ok"
|
||||
|
||||
|
||||
class _UniversalTool(Tool):
|
||||
_scopes = {"core", "subagent", "memory"}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return "universal"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return "..."
|
||||
|
||||
@property
|
||||
def parameters(self):
|
||||
return {"type": "object"}
|
||||
|
||||
async def execute(self, **_):
|
||||
return "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loader_filters_by_scope():
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
loader = ToolLoader(test_classes=[_CoreOnlyTool, _SubagentOnlyTool, _UniversalTool])
|
||||
|
||||
registry = ToolRegistry()
|
||||
ctx = ToolContext(config={}, workspace="/tmp")
|
||||
loader.load(ctx, registry, scope="core")
|
||||
|
||||
assert registry.has("core_only")
|
||||
assert not registry.has("sub_only")
|
||||
assert registry.has("universal")
|
||||
@@ -399,7 +399,6 @@ class TestConsolidationUnaffectedByUnifiedSession:
|
||||
# estimate was called (consolidation was attempted)
|
||||
consolidator.estimate_session_prompt_tokens.assert_called_once_with(
|
||||
session,
|
||||
session_summary=None,
|
||||
)
|
||||
# but archive was not called (no valid boundary)
|
||||
consolidator.archive.assert_not_called()
|
||||
|
||||
@@ -4,14 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,10 +58,10 @@ def _make_mock_loop(**overrides):
|
||||
return loop
|
||||
|
||||
|
||||
def _make_tool(loop=None):
|
||||
if loop is None:
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(loop=loop)
|
||||
def _make_tool(runtime_state=None):
|
||||
if runtime_state is None:
|
||||
runtime_state = _make_mock_loop()
|
||||
return MyTool(runtime_state=runtime_state)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,7 +81,7 @@ class TestInspectSummary:
|
||||
async def test_inspect_includes_runtime_vars(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {"task": "review"}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check")
|
||||
assert "task" in result
|
||||
|
||||
@@ -144,7 +143,7 @@ class TestInspectPathNavigation:
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="web_config.enable")
|
||||
assert "True" in result
|
||||
|
||||
@@ -152,7 +151,7 @@ class TestInspectPathNavigation:
|
||||
async def test_inspect_dict_key_via_dotpath(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||
assert "100" in result
|
||||
|
||||
@@ -201,14 +200,14 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert "Set max_iterations = 80" in result
|
||||
assert tool._loop.max_iterations == 80
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_out_of_range(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||
assert "Error" in result
|
||||
assert tool._loop.max_iterations == 40
|
||||
assert tool._runtime_state.max_iterations == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_max_exceeded(self):
|
||||
@@ -232,13 +231,13 @@ class TestModifyRestricted:
|
||||
async def test_modify_string_int_coerced(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||
assert tool._loop.max_iterations == 80
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert tool._loop.context_window_tokens == 131072
|
||||
assert tool._runtime_state.context_window_tokens == 131072
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_none_value_for_restricted_int(self):
|
||||
@@ -312,7 +311,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||
assert "Set provider_retry_mode" in result
|
||||
assert tool._loop.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||
@@ -320,7 +319,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||
assert "my_custom_var" in result
|
||||
assert tool._loop._runtime_vars["my_custom_var"] == "hello"
|
||||
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_rejects_callable(self):
|
||||
@@ -338,13 +337,13 @@ class TestModifyFree:
|
||||
async def test_modify_allows_list(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||
assert tool._loop._runtime_vars["items"] == [1, 2, 3]
|
||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||
assert tool._loop._runtime_vars["data"] == {"a": 1}
|
||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_whitespace_key_rejected(self):
|
||||
@@ -382,7 +381,7 @@ class TestModifyFree:
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||
assert "Error" in result
|
||||
assert "str" in result
|
||||
assert tool._loop.provider_retry_mode == "standard"
|
||||
assert tool._runtime_state.provider_retry_mode == "standard"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||
@@ -390,7 +389,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||
assert "Error" in result
|
||||
assert tool._loop.max_tool_result_chars == 16000
|
||||
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -579,7 +578,7 @@ class TestRuntimeVarsLimits:
|
||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="set", key="overflow", value="data")
|
||||
assert "full" in result
|
||||
assert "overflow" not in loop._runtime_vars
|
||||
@@ -588,7 +587,7 @@ class TestRuntimeVarsLimits:
|
||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||
assert "Error" not in result
|
||||
assert loop._runtime_vars["key_0"] == "updated"
|
||||
@@ -689,8 +688,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_updates_status(self):
|
||||
"""after_iteration should copy iteration, tool_events, usage to status."""
|
||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||
from nanobot.agent.hook import AgentHookContext
|
||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="test",
|
||||
@@ -716,8 +715,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_with_error(self):
|
||||
"""after_iteration should set status.error when context has an error."""
|
||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||
from nanobot.agent.hook import AgentHookContext
|
||||
from nanobot.agent.subagent import SubagentStatus, _SubagentHook
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="test",
|
||||
@@ -739,8 +738,8 @@ class TestSubagentHookStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_no_status_is_noop(self):
|
||||
"""after_iteration with no status should be a no-op."""
|
||||
from nanobot.agent.subagent import _SubagentHook
|
||||
from nanobot.agent.hook import AgentHookContext
|
||||
from nanobot.agent.subagent import _SubagentHook
|
||||
|
||||
hook = _SubagentHook("test")
|
||||
context = AgentHookContext(iteration=1, messages=[])
|
||||
@@ -756,8 +755,8 @@ class TestCheckpointCallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint_updates_phase_and_iteration(self):
|
||||
"""The _on_checkpoint callback should update status.phase and iteration."""
|
||||
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
import asyncio
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id="cp",
|
||||
@@ -827,7 +826,7 @@ class TestInspectTaskStatuses:
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||
),
|
||||
}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||
assert "abc12345" in result
|
||||
assert "read logs" in result
|
||||
@@ -848,7 +847,7 @@ class TestInspectTaskStatuses:
|
||||
stop_reason="completed",
|
||||
)
|
||||
loop.subagents._task_statuses = {"xyz": status}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||
assert "search code" in result
|
||||
assert "completed" in result
|
||||
@@ -862,7 +861,7 @@ class TestReadOnlyMode:
|
||||
|
||||
def _make_readonly_tool(self):
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(loop=loop, modify_allowed=False)
|
||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_allowed_in_readonly(self):
|
||||
@@ -941,7 +940,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
loop.some_config.password = "hunter2"
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="some_config.password")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -950,7 +949,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.vault = MagicMock()
|
||||
loop.vault.secret = "classified"
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="vault.secret")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -959,7 +958,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.auth_data = MagicMock()
|
||||
loop.auth_data.token = "jwt-payload"
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check", key="auth_data.token")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -975,7 +974,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
async def test_modify_password_blocked(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -1107,7 +1106,7 @@ class TestLastUsageInSummary:
|
||||
async def test_last_usage_not_shown_when_empty(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {}
|
||||
tool = _make_tool(loop)
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="check")
|
||||
assert "_last_usage" not in result
|
||||
|
||||
@@ -1119,7 +1118,8 @@ class TestLastUsageInSummary:
|
||||
class TestSetContext:
|
||||
|
||||
def test_set_context_stores_channel_and_chat_id(self):
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
tool = _make_tool()
|
||||
tool.set_context("feishu", "oc_abc123")
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="oc_abc123"))
|
||||
assert tool._channel == "feishu"
|
||||
assert tool._chat_id == "oc_abc123"
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||
|
||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||
|
||||
tool = MyTool(loop=loop)
|
||||
tool = MyTool(runtime_state=loop)
|
||||
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
||||
"""allowed_env_keys from ExecToolConfig must be forwarded to the subagent's ExecTool."""
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
@@ -27,7 +28,7 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
||||
workspace=tmp_path,
|
||||
bus=bus,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
exec_config=ExecToolConfig(allowed_env_keys=["GOPATH", "JAVA_HOME"]),
|
||||
tools_config=ToolsConfig(exec=ExecToolConfig(allowed_env_keys=["GOPATH", "JAVA_HOME"])),
|
||||
)
|
||||
mgr._announce_result = AsyncMock()
|
||||
|
||||
@@ -125,8 +126,10 @@ async def test_spawn_tool_rejects_when_at_concurrency_limit(tmp_path):
|
||||
|
||||
mgr.runner.run = AsyncMock(side_effect=fake_run)
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
|
||||
tool = SpawnTool(mgr)
|
||||
tool.set_context("test", "c1", "test:c1")
|
||||
tool.set_context(RequestContext(channel="test", chat_id="c1", session_key="test:c1"))
|
||||
|
||||
# First spawn succeeds
|
||||
result = await tool.execute(task="first task")
|
||||
|
||||
@@ -25,7 +25,11 @@ from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
|
||||
def _make_feishu_channel(
|
||||
reply_to_message: bool = False,
|
||||
group_policy: str = "mention",
|
||||
topic_isolation: bool = True,
|
||||
) -> FeishuChannel:
|
||||
config = FeishuConfig(
|
||||
enabled=True,
|
||||
app_id="cli_test",
|
||||
@@ -33,6 +37,7 @@ def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "me
|
||||
allow_from=["*"],
|
||||
reply_to_message=reply_to_message,
|
||||
group_policy=group_policy,
|
||||
topic_isolation=topic_isolation,
|
||||
)
|
||||
channel = FeishuChannel(config, MessageBus())
|
||||
channel._client = MagicMock()
|
||||
@@ -95,6 +100,20 @@ def test_feishu_config_reply_to_message_can_be_enabled() -> None:
|
||||
assert config.reply_to_message is True
|
||||
|
||||
|
||||
def test_feishu_config_topic_isolation_defaults_true() -> None:
|
||||
assert FeishuConfig().topic_isolation is True
|
||||
|
||||
|
||||
def test_feishu_config_topic_isolation_can_be_disabled() -> None:
|
||||
config = FeishuConfig(topic_isolation=False)
|
||||
assert config.topic_isolation is False
|
||||
|
||||
|
||||
def test_feishu_config_topic_isolation_accepts_camel_case() -> None:
|
||||
config = FeishuConfig.model_validate({"topicIsolation": False})
|
||||
assert config.topic_isolation is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_message_content_sync tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -912,3 +931,93 @@ async def test_on_message_ignores_unauthorized_sender_before_side_effects() -> N
|
||||
channel._download_and_save_media.assert_not_awaited()
|
||||
channel.transcribe_audio.assert_not_awaited()
|
||||
channel._handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_with_topic_isolation_true_uses_thread_scoped() -> None:
|
||||
"""When topic_isolation is True (default), group messages use thread-scoped session keys."""
|
||||
channel = _make_feishu_channel(group_policy="open", topic_isolation=True)
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
# Test with root_id
|
||||
event1 = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id="om_root123",
|
||||
message_id="om_child456",
|
||||
)
|
||||
await channel._on_message(event1)
|
||||
|
||||
# Test without root_id
|
||||
event2 = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "another"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event2)
|
||||
|
||||
assert len(bus_spy) == 2
|
||||
assert bus_spy[0].session_key_override == "feishu:oc_abc:om_root123"
|
||||
assert bus_spy[1].session_key_override == "feishu:oc_abc:om_001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_key_with_topic_isolation_false_uses_group_scoped() -> None:
|
||||
"""When topic_isolation is False, all group messages share the same session key (no isolation)."""
|
||||
channel = _make_feishu_channel(group_policy="open", topic_isolation=False)
|
||||
bus_spy = []
|
||||
original_publish = channel.bus.publish_inbound
|
||||
|
||||
async def capture(msg):
|
||||
bus_spy.append(msg)
|
||||
await original_publish(msg)
|
||||
|
||||
channel.bus.publish_inbound = capture
|
||||
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
|
||||
channel.transcribe_audio = AsyncMock(return_value="")
|
||||
channel._add_reaction = AsyncMock(return_value=None)
|
||||
|
||||
# Test with root_id
|
||||
event1 = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "hello"}',
|
||||
root_id="om_root123",
|
||||
message_id="om_child456",
|
||||
)
|
||||
await channel._on_message(event1)
|
||||
|
||||
# Test without root_id
|
||||
event2 = _make_feishu_event(
|
||||
chat_type="group",
|
||||
content='{"text": "another"}',
|
||||
root_id=None,
|
||||
message_id="om_001",
|
||||
)
|
||||
await channel._on_message(event2)
|
||||
|
||||
# Private chat still works
|
||||
event3 = _make_feishu_event(
|
||||
chat_type="p2p",
|
||||
content='{"text": "private"}',
|
||||
root_id=None,
|
||||
message_id="om_private",
|
||||
)
|
||||
await channel._on_message(event3)
|
||||
|
||||
assert len(bus_spy) == 3
|
||||
# Group messages all share the same key
|
||||
assert bus_spy[0].session_key_override == "feishu:oc_abc"
|
||||
assert bus_spy[1].session_key_override == "feishu:oc_abc"
|
||||
# Private chat has no session key override
|
||||
assert bus_spy[2].session_key_override is None
|
||||
|
||||
@@ -234,13 +234,13 @@ async def test_send_renders_buttons_on_last_message_chunk() -> None:
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "Yes"},
|
||||
"value": "Yes",
|
||||
"action_id": "ask_user_Yes",
|
||||
"action_id": "btn_Yes",
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": {"type": "plain_text", "text": "No"},
|
||||
"value": "No",
|
||||
"action_id": "ask_user_No",
|
||||
"action_id": "btn_No",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.websocket import (
|
||||
WebSocketChannel,
|
||||
WebSocketConfig,
|
||||
@@ -25,6 +26,7 @@ from nanobot.channels.websocket import (
|
||||
_parse_inbound_payload,
|
||||
_parse_query,
|
||||
_parse_request_path,
|
||||
publish_runtime_model_update,
|
||||
)
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
@@ -222,11 +224,46 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["event"] == "message"
|
||||
assert payload["chat_id"] == "chat-1"
|
||||
assert payload["text"] == "hello\n\n1. Yes\n2. No"
|
||||
assert payload["button_prompt"] == "hello"
|
||||
assert payload["text"] == "hello"
|
||||
assert payload["reply_to"] == "m1"
|
||||
assert payload["media"] == ["/tmp/a.png"]
|
||||
assert payload["buttons"] == [["Yes", "No"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_broadcasts_runtime_model_updates() -> None:
|
||||
bus = MessageBus()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
|
||||
await channel.send(bus.outbound.get_nowait())
|
||||
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["event"] == "runtime_model_updated"
|
||||
assert payload["model_name"] == "openai/gpt-4.1"
|
||||
assert payload["model_preset"] == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||
bus = MessageBus()
|
||||
|
||||
publish_runtime_model_update(
|
||||
bus,
|
||||
"openai/gpt-4.1",
|
||||
"fast",
|
||||
)
|
||||
|
||||
event = bus.outbound.get_nowait()
|
||||
assert event.channel == "websocket"
|
||||
assert event.chat_id == "*"
|
||||
assert event.content == ""
|
||||
assert event.metadata == {
|
||||
"_runtime_model_updated": True,
|
||||
"model": "openai/gpt-4.1",
|
||||
"model_preset": "fast",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -524,6 +561,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
config = Config()
|
||||
config.agents.defaults.model = "openai/gpt-4o"
|
||||
config.providers.openai.api_key = "secret-key"
|
||||
config.tools.web.search.provider = "brave"
|
||||
config.tools.web.search.api_key = "brave-secret"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
@@ -547,7 +586,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert providers["openai"]["api_key_hint"] == "secr••••-key"
|
||||
assert providers["openrouter"]["configured"] is False
|
||||
assert body["agent"]["has_api_key"] is True
|
||||
assert body["web_search"]["provider"] == "brave"
|
||||
assert body["web_search"]["api_key_hint"] == "brav••••cret"
|
||||
search_providers = {provider["name"]: provider for provider in body["web_search"]["providers"]}
|
||||
assert search_providers["duckduckgo"]["credential"] == "none"
|
||||
assert search_providers["searxng"]["credential"] == "base_url"
|
||||
assert "secret-key" not in settings.text
|
||||
assert "brave-secret" not in settings.text
|
||||
|
||||
provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
@@ -571,11 +616,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["requires_restart"] is False
|
||||
|
||||
search_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||
"&base_url=https%3A%2F%2Fsearch.example.com",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert search_updated.status_code == 200
|
||||
search_body = search_updated.json()
|
||||
assert search_body["requires_restart"] is False
|
||||
assert search_body["web_search"]["provider"] == "searxng"
|
||||
assert search_body["web_search"]["api_key_hint"] is None
|
||||
assert search_body["web_search"]["base_url"] == "https://search.example.com"
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model == "openrouter/test"
|
||||
assert saved.agents.defaults.provider == "openrouter"
|
||||
assert saved.providers.openrouter.api_key == "sk-or-test"
|
||||
assert saved.providers.openrouter.api_base == "https://openrouter.ai/api/v1"
|
||||
assert saved.tools.web.search.provider == "searxng"
|
||||
assert saved.tools.web.search.api_key == ""
|
||||
assert saved.tools.web.search.base_url == "https://search.example.com"
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
@@ -552,6 +552,26 @@ async def test_process_file_message() -> None:
|
||||
os.unlink(p)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_file_message_uses_sdk_filename_when_name_missing(tmp_path: Path) -> None:
|
||||
"""Without `file.name`, fall back to SDK fname instead of saving as 'unknown' (#3737)."""
|
||||
channel = WecomChannel(WecomConfig(bot_id="b", secret="s", allow_from=["user1"]), MessageBus())
|
||||
client = _FakeWeComClient()
|
||||
client.download_file.return_value = (b"%PDF-1.4 fake", "real_name.pdf")
|
||||
channel._client = client
|
||||
|
||||
with patch("nanobot.channels.wecom.get_media_dir", return_value=tmp_path):
|
||||
frame = _FakeFrame(body={
|
||||
"msgid": "msg_file_2", "chatid": "chat1", "from": {"userid": "user1"},
|
||||
"file": {"url": "https://example.com/x", "aeskey": "key456"},
|
||||
})
|
||||
await channel._process_message(frame, "file")
|
||||
|
||||
msg = await channel.bus.consume_inbound()
|
||||
assert msg.media == [str(tmp_path / "real_name.pdf")]
|
||||
assert "[file: real_name.pdf]" in msg.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_voice_message() -> None:
|
||||
"""Voice message: transcribed text is included in content."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for configurable bot identity in CLI (#3650)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
|
||||
from nanobot.config.schema import AgentDefaults, Config
|
||||
|
||||
|
||||
def test_bot_name_and_icon_defaults_preserve_current_branding() -> None:
|
||||
"""Default values keep the existing 'nanobot' name and cat icon."""
|
||||
defaults = AgentDefaults()
|
||||
|
||||
assert defaults.bot_name == "nanobot"
|
||||
assert defaults.bot_icon == "🐈"
|
||||
|
||||
|
||||
def test_bot_name_and_icon_can_be_overridden_via_config() -> None:
|
||||
"""camelCase keys (as used in config.json) bind to the new fields."""
|
||||
config = Config.model_validate(
|
||||
{"agents": {"defaults": {"botName": "mybot", "botIcon": "🤖"}}}
|
||||
)
|
||||
|
||||
assert config.agents.defaults.bot_name == "mybot"
|
||||
assert config.agents.defaults.bot_icon == "🤖"
|
||||
|
||||
|
||||
def test_bot_icon_accepts_empty_string_to_omit() -> None:
|
||||
"""Empty bot_icon is valid and lets users opt out of the leading icon."""
|
||||
config = Config.model_validate(
|
||||
{"agents": {"defaults": {"botIcon": ""}}}
|
||||
)
|
||||
|
||||
assert config.agents.defaults.bot_icon == ""
|
||||
|
||||
|
||||
def test_stream_renderer_propagates_bot_name_to_spinner_text(capsys) -> None:
|
||||
"""ThinkingSpinner uses the configured bot_name in its status text."""
|
||||
spinner = ThinkingSpinner(bot_name="mybot")
|
||||
|
||||
# rich.Status keeps the renderable on its internal _renderable attribute;
|
||||
# the spinner text is exposed via its underlying status text.
|
||||
rendered = spinner._spinner.status
|
||||
assert "mybot is thinking..." in rendered
|
||||
|
||||
|
||||
def test_stream_renderer_header_combines_icon_and_name() -> None:
|
||||
"""When bot_icon is non-empty, the header is '<icon> <name>'."""
|
||||
renderer = StreamRenderer(show_spinner=False, bot_name="mybot", bot_icon="🤖")
|
||||
|
||||
# The header is built inline in on_delta; verify the stored fields
|
||||
# so we don't depend on Live console output.
|
||||
assert renderer._bot_name == "mybot"
|
||||
assert renderer._bot_icon == "🤖"
|
||||
|
||||
|
||||
def test_stream_renderer_empty_icon_omits_leading_space() -> None:
|
||||
"""An empty bot_icon yields a header that is just the bot name, no leading space."""
|
||||
renderer = StreamRenderer(show_spinner=False, bot_name="mybot", bot_icon="")
|
||||
|
||||
# Replicate the header construction used in on_delta to assert the contract.
|
||||
header = (
|
||||
f"{renderer._bot_icon} {renderer._bot_name}"
|
||||
if renderer._bot_icon
|
||||
else renderer._bot_name
|
||||
)
|
||||
assert header == "mybot"
|
||||
+66
-30
@@ -9,7 +9,8 @@ import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.cli.commands import _make_provider, app
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
@@ -19,6 +20,13 @@ from nanobot.providers.registry import find_by_name
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _fake_provider():
|
||||
"""Return a minimal fake provider that satisfies AgentLoop.__init__."""
|
||||
p = MagicMock()
|
||||
p.generation.max_tokens = 4096
|
||||
return p
|
||||
|
||||
|
||||
class _StopGatewayError(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -488,7 +496,7 @@ def test_openai_compat_provider_passes_model_through():
|
||||
|
||||
|
||||
def test_make_provider_uses_github_copilot_backend():
|
||||
from nanobot.cli.commands import _make_provider
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config = Config.model_validate(
|
||||
@@ -503,7 +511,7 @@ def test_make_provider_uses_github_copilot_backend():
|
||||
)
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = _make_provider(config)
|
||||
provider = make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
@@ -579,7 +587,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||
)
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
|
||||
_make_provider(config)
|
||||
make_provider(config)
|
||||
|
||||
kwargs = mock_async_openai.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
@@ -597,24 +605,24 @@ def mock_agent_runtime(tmp_path):
|
||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||
patch("nanobot.cli.commands._make_provider", return_value=object()), \
|
||||
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||
patch("nanobot.bus.queue.MessageBus"), \
|
||||
patch("nanobot.cron.service.CronService"), \
|
||||
patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
|
||||
patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
|
||||
agent_loop = MagicMock()
|
||||
agent_loop.channels_config = None
|
||||
agent_loop.process_direct = AsyncMock(
|
||||
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
||||
)
|
||||
agent_loop.close_mcp = AsyncMock(return_value=None)
|
||||
mock_agent_loop_cls.return_value = agent_loop
|
||||
mock_from_config.return_value = agent_loop
|
||||
|
||||
yield {
|
||||
"config": config,
|
||||
"load_config": mock_load_config,
|
||||
"sync_templates": mock_sync_templates,
|
||||
"agent_loop_cls": mock_agent_loop_cls,
|
||||
"from_config": mock_from_config,
|
||||
"agent_loop": agent_loop,
|
||||
"print_response": mock_print_response,
|
||||
}
|
||||
@@ -639,9 +647,8 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
||||
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
||||
mock_agent_runtime["config"].workspace_path,
|
||||
)
|
||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
|
||||
mock_agent_runtime["config"].workspace_path
|
||||
)
|
||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||
assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
|
||||
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
||||
mock_agent_runtime["print_response"].assert_called_once_with(
|
||||
"mock-response", render_markdown=True, metadata={},
|
||||
@@ -672,11 +679,14 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
@@ -686,7 +696,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
||||
async def close_mcp(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||
@@ -707,7 +717,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
|
||||
class _FakeCron:
|
||||
@@ -715,6 +725,9 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
||||
seen["cron_store"] = store_path
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
@@ -725,7 +738,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||
@@ -753,7 +766,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
|
||||
@@ -762,6 +775,9 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
||||
seen["cron_store"] = store_path
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
@@ -772,7 +788,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = runner.invoke(
|
||||
@@ -806,7 +822,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
|
||||
@@ -815,6 +831,9 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
seen["cron_store"] = store_path
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
@@ -825,7 +844,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
||||
)
|
||||
@@ -846,7 +865,8 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
|
||||
assert result.exit_code == 0
|
||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||
assert passed_config.workspace_path == workspace_path
|
||||
|
||||
|
||||
def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
|
||||
@@ -863,7 +883,8 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
||||
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
|
||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||
assert passed_config.workspace_path == workspace_path
|
||||
|
||||
|
||||
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
||||
@@ -915,7 +936,7 @@ def _patch_cli_command_runtime(
|
||||
cron_service=None,
|
||||
get_cron_dir=None,
|
||||
) -> None:
|
||||
provider_factory = make_provider or (lambda _config: object())
|
||||
provider_factory = make_provider or (lambda _config: _fake_provider())
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
@@ -928,7 +949,7 @@ def _patch_cli_command_runtime(
|
||||
sync_templates or (lambda _path: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
"nanobot.providers.factory.make_provider",
|
||||
provider_factory,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -959,6 +980,9 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
||||
self.on_cleanup: list[object] = []
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(workspace=config.workspace_path, **extra)
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["workspace"] = kwargs["workspace"]
|
||||
|
||||
@@ -985,7 +1009,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||
monkeypatch.setattr("aiohttp.web.run_app", _fake_run_app)
|
||||
|
||||
@@ -1069,7 +1093,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
provider = object()
|
||||
provider = _fake_provider()
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
seen: dict[str, object] = {}
|
||||
@@ -1077,7 +1101,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -1115,8 +1139,12 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
seen["cron"] = self
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = kwargs.get("provider", object())
|
||||
self.tools = {}
|
||||
|
||||
async def process_direct(self, *_args, **_kwargs):
|
||||
@@ -1152,7 +1180,7 @@ def test_gateway_cron_evaluator_receives_scheduled_reminder_context(
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.evaluator.evaluate_response",
|
||||
@@ -1228,7 +1256,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(object(), _config),
|
||||
@@ -1246,8 +1274,12 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
seen["cron"] = self
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.tools = {}
|
||||
|
||||
async def process_direct(self, *_args, on_progress=None, **_kwargs):
|
||||
@@ -1275,7 +1307,7 @@ def test_gateway_cron_job_suppresses_intermediate_progress(
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _StopAfterCronSetup)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.utils.evaluator.evaluate_response",
|
||||
@@ -1478,8 +1510,12 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
return 0
|
||||
|
||||
class _FakeAgentLoop:
|
||||
@classmethod
|
||||
def from_config(cls, config, bus=None, **extra):
|
||||
return cls(**extra)
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.dream = _FakeDream()
|
||||
self.sessions = _FakeSessionManager()
|
||||
|
||||
@@ -1571,7 +1607,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCronService)
|
||||
monkeypatch.setattr("nanobot.heartbeat.service.HeartbeatService", _FakeHeartbeatService)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command.builtin import (
|
||||
build_help_text,
|
||||
builtin_command_palette,
|
||||
cmd_model,
|
||||
register_builtin_commands,
|
||||
)
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
|
||||
|
||||
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = default_model
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _make_loop(tmp_path) -> AgentLoop:
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider("base-model", max_tokens=123),
|
||||
workspace=tmp_path,
|
||||
model="base-model",
|
||||
context_window_tokens=1000,
|
||||
model_presets={
|
||||
"default": ModelPresetConfig(
|
||||
model="base-model",
|
||||
max_tokens=123,
|
||||
context_window_tokens=1000,
|
||||
),
|
||||
"fast": ModelPresetConfig(
|
||||
model="openai/gpt-4.1",
|
||||
max_tokens=4096,
|
||||
context_window_tokens=32_768,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ctx(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
|
||||
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model"))
|
||||
|
||||
assert "Current model: `base-model`" in out.content
|
||||
assert "Current preset: `default`" in out.content
|
||||
assert "Available presets: `default`, `fast`" in out.content
|
||||
assert "`fast`" in out.content
|
||||
assert out.metadata == {"render_as": "text"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_switches_preset(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model fast", args="fast"))
|
||||
|
||||
assert "Switched model preset to `fast`." in out.content
|
||||
assert "Model: `openai/gpt-4.1`" in out.content
|
||||
assert loop.model_preset == "fast"
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.subagents.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.model == "openai/gpt-4.1"
|
||||
assert loop.dream.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_switches_back_to_default(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.set_model_preset("fast")
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model default", args="default"))
|
||||
|
||||
assert "Switched model preset to `default`." in out.content
|
||||
assert loop.model_preset == "default"
|
||||
assert loop.model == "base-model"
|
||||
assert loop.context_window_tokens == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_unknown_preset_keeps_old_state(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await cmd_model(_ctx(loop, "/model missing", args="missing"))
|
||||
|
||||
assert "Could not switch model preset" in out.content
|
||||
assert "\"model_preset" not in out.content
|
||||
assert "Available presets: `default`, `fast`" in out.content
|
||||
assert loop.model_preset is None
|
||||
assert loop.model == "base-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_does_not_depend_on_my_allow_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.tools_config.my.allow_set is False
|
||||
|
||||
await cmd_model(_ctx(loop, "/model fast", args="fast"))
|
||||
|
||||
assert loop.model_preset == "fast"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None:
|
||||
router = CommandRouter()
|
||||
register_builtin_commands(router)
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
out = await router.dispatch(_ctx(loop, "/model fast"))
|
||||
|
||||
assert out is not None
|
||||
assert "Switched model preset" in out.content
|
||||
assert loop.model_preset == "fast"
|
||||
|
||||
|
||||
def test_model_command_in_help_and_palette() -> None:
|
||||
palette = builtin_command_palette()
|
||||
|
||||
assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette)
|
||||
assert "/model [preset]" in build_help_text()
|
||||
@@ -22,6 +22,7 @@ class TestIsDispatchableCommand:
|
||||
def test_exact_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/new")
|
||||
assert router.is_dispatchable_command("/help")
|
||||
assert router.is_dispatchable_command("/model")
|
||||
assert router.is_dispatchable_command("/dream")
|
||||
assert router.is_dispatchable_command("/dream-log")
|
||||
assert router.is_dispatchable_command("/dream-restore")
|
||||
@@ -29,6 +30,7 @@ class TestIsDispatchableCommand:
|
||||
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
||||
assert router.is_dispatchable_command("/dream-log abc123")
|
||||
assert router.is_dispatchable_command("/dream-restore def456")
|
||||
assert router.is_dispatchable_command("/model fast")
|
||||
|
||||
def test_priority_commands_not_matched(self, router: CommandRouter) -> None:
|
||||
# Priority commands are NOT in the dispatchable tiers — they are
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
||||
config = Config()
|
||||
resolved = config.resolve_preset()
|
||||
assert resolved.model == config.agents.defaults.model
|
||||
assert resolved.provider == config.agents.defaults.provider
|
||||
assert resolved.max_tokens == config.agents.defaults.max_tokens
|
||||
assert resolved.context_window_tokens == config.agents.defaults.context_window_tokens
|
||||
assert resolved.temperature == config.agents.defaults.temperature
|
||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
||||
|
||||
|
||||
def test_legacy_defaults_config_without_presets_still_resolves() -> None:
|
||||
config = Config.model_validate({
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"provider": "openai",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 128_000,
|
||||
"temperature": 0.2,
|
||||
"reasoningEffort": "low",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
resolved = config.resolve_preset()
|
||||
assert config.agents.defaults.model_preset is None
|
||||
assert config.model_presets == {}
|
||||
assert resolved.model == "openai/gpt-4.1"
|
||||
assert resolved.provider == "openai"
|
||||
assert resolved.max_tokens == 4096
|
||||
assert resolved.context_window_tokens == 128_000
|
||||
assert resolved.temperature == 0.2
|
||||
assert resolved.reasoning_effort == "low"
|
||||
|
||||
|
||||
def test_resolve_preset_returns_active_preset() -> None:
|
||||
config = Config.model_validate({
|
||||
"model_presets": {
|
||||
"fast": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"provider": "openai",
|
||||
"maxTokens": 4096,
|
||||
"contextWindowTokens": 32_768,
|
||||
"temperature": 0.5,
|
||||
"reasoningEffort": "low",
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "fast",
|
||||
}
|
||||
},
|
||||
})
|
||||
resolved = config.resolve_preset()
|
||||
assert resolved.model == "openai/gpt-4.1"
|
||||
assert resolved.provider == "openai"
|
||||
assert resolved.max_tokens == 4096
|
||||
assert resolved.context_window_tokens == 32_768
|
||||
assert resolved.temperature == 0.5
|
||||
assert resolved.reasoning_effort == "low"
|
||||
|
||||
|
||||
def test_default_preset_is_agents_defaults_even_when_named_preset_is_active() -> None:
|
||||
config = Config.model_validate({
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"provider": "openai",
|
||||
"modelPreset": "fast",
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"fast": {"model": "openai/gpt-4.1-mini", "provider": "openai"},
|
||||
},
|
||||
})
|
||||
|
||||
assert config.resolve_preset().model == "openai/gpt-4.1-mini"
|
||||
assert config.resolve_preset("default").model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_model_presets_accepts_camel_case_root_key() -> None:
|
||||
config = Config.model_validate({
|
||||
"modelPresets": {
|
||||
"fast": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"provider": "openai",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert config.model_presets["fast"].model == "openai/gpt-4.1"
|
||||
assert config.model_presets["fast"].provider == "openai"
|
||||
|
||||
|
||||
def test_resolve_preset_can_target_named_preset_without_activating() -> None:
|
||||
config = Config.model_validate({
|
||||
"model_presets": {
|
||||
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||
"deep": {"model": "anthropic/claude-opus-4-5", "provider": "anthropic"},
|
||||
},
|
||||
"agents": {"defaults": {"modelPreset": "fast"}},
|
||||
})
|
||||
|
||||
resolved = config.resolve_preset("deep")
|
||||
assert resolved.model == "anthropic/claude-opus-4-5"
|
||||
assert resolved.provider == "anthropic"
|
||||
|
||||
|
||||
def test_validator_rejects_unknown_preset() -> None:
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="model_preset 'unknown' not found in model_presets"):
|
||||
Config.model_validate({
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "unknown",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
def test_model_preset_accepts_explicit_default_name() -> None:
|
||||
config = Config.model_validate({
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"modelPreset": "default",
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
assert config.resolve_preset().model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_model_presets_rejects_reserved_default_name() -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="model_preset name 'default' is reserved"):
|
||||
Config.model_validate({
|
||||
"modelPresets": {
|
||||
"default": {"model": "custom-model"},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def test_resolve_preset_rejects_unknown_named_preset() -> None:
|
||||
import pytest
|
||||
with pytest.raises(KeyError, match="model_preset 'missing' not found"):
|
||||
Config().resolve_preset("missing")
|
||||
|
||||
|
||||
def test_match_provider_uses_preset_model() -> None:
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
"openai": {"apiKey": "sk-test"},
|
||||
},
|
||||
"model_presets": {
|
||||
"fast": {
|
||||
"model": "openai/gpt-4.1",
|
||||
"provider": "openai",
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "fast",
|
||||
}
|
||||
},
|
||||
})
|
||||
name = config.get_provider_name()
|
||||
assert name == "openai"
|
||||
|
||||
|
||||
def test_match_provider_uses_preset_provider_when_forced() -> None:
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
"anthropic": {"apiKey": "sk-test"},
|
||||
},
|
||||
"model_presets": {
|
||||
"fast": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"provider": "anthropic",
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "fast",
|
||||
}
|
||||
},
|
||||
})
|
||||
name = config.get_provider_name()
|
||||
assert name == "anthropic"
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob, CronJobState, CronPayload, CronSchedule
|
||||
@@ -302,7 +303,7 @@ def test_remove_protected_dream_job_returns_clear_feedback(tmp_path) -> None:
|
||||
|
||||
def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context("telegram", "chat-1")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "Morning standup", None, "0 8 * * *", None, None)
|
||||
|
||||
@@ -313,7 +314,7 @@ def test_add_cron_job_defaults_to_tool_timezone(tmp_path) -> None:
|
||||
|
||||
def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
tool = _make_tool_with_tz(tmp_path, "Asia/Shanghai")
|
||||
tool.set_context("telegram", "chat-1")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "Morning reminder", None, None, None, "2026-03-25T08:00:00")
|
||||
|
||||
@@ -325,7 +326,7 @@ def test_add_at_job_uses_default_timezone_for_naive_datetime(tmp_path) -> None:
|
||||
|
||||
def test_add_job_delivers_by_default(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context("telegram", "chat-1")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "Morning standup", 60, None, None, None)
|
||||
|
||||
@@ -336,7 +337,7 @@ def test_add_job_delivers_by_default(tmp_path) -> None:
|
||||
|
||||
def test_add_job_can_disable_delivery(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context("telegram", "chat-1")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "Background refresh", 60, None, None, None, deliver=False)
|
||||
|
||||
@@ -374,7 +375,7 @@ def test_validate_params_requires_message_only_for_add(tmp_path) -> None:
|
||||
|
||||
def test_add_job_empty_message_returns_actionable_error(tmp_path) -> None:
|
||||
tool = _make_tool(tmp_path)
|
||||
tool.set_context("telegram", "chat-1")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-1"))
|
||||
|
||||
result = tool._add_job(None, "", 60, None, None, None)
|
||||
|
||||
@@ -386,7 +387,9 @@ def test_add_job_captures_metadata_and_session_key(tmp_path) -> None:
|
||||
"""CronTool stores channel metadata and session_key when adding a job."""
|
||||
tool = _make_tool(tmp_path)
|
||||
meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
tool.set_context("slack", "C99", metadata=meta, session_key="slack:C99:111.222")
|
||||
tool.set_context(RequestContext(
|
||||
channel="slack", chat_id="C99", metadata=meta, session_key="slack:C99:111.222"
|
||||
))
|
||||
|
||||
result = tool._add_job("test", "say hi", 60, None, None, None)
|
||||
assert "Created job" in result
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
@@ -40,7 +41,7 @@ class _SvcStub:
|
||||
@pytest.fixture
|
||||
def registry() -> ToolRegistry:
|
||||
tool = CronTool(_SvcStub(), default_timezone="UTC")
|
||||
tool.set_context("channel", "chat-id")
|
||||
tool.set_context(RequestContext(channel="channel", chat_id="chat-id"))
|
||||
reg = ToolRegistry()
|
||||
reg.register(tool)
|
||||
return reg
|
||||
|
||||
@@ -106,6 +106,7 @@ def test_generic_bedrock_model_keeps_temperature_and_skips_anthropic_thinking()
|
||||
assert kwargs["modelId"] == "amazon.nova-lite-v1:0"
|
||||
assert kwargs["inferenceConfig"] == {"maxTokens": 1024, "temperature": 0.3}
|
||||
assert "additionalModelRequestFields" not in kwargs
|
||||
assert "toolConfig" not in kwargs
|
||||
|
||||
|
||||
def test_build_kwargs_converts_messages_tools_and_tool_results() -> None:
|
||||
@@ -160,6 +161,39 @@ def test_build_kwargs_converts_messages_tools_and_tool_results() -> None:
|
||||
assert kwargs["toolConfig"]["toolChoice"] == {"any": {}}
|
||||
|
||||
|
||||
def test_build_kwargs_keeps_tool_config_for_historical_tool_blocks_without_tools() -> None:
|
||||
provider = BedrockProvider(region="us-east-1", client=FakeClient())
|
||||
messages = [
|
||||
{"role": "user", "content": "read x"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "toolu_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path": "x"}'},
|
||||
}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "toolu_1", "name": "read_file", "content": "ok"},
|
||||
{"role": "user", "content": "continue"},
|
||||
]
|
||||
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=messages,
|
||||
tools=[],
|
||||
model="bedrock/anthropic.claude-opus-4-7",
|
||||
max_tokens=1024,
|
||||
temperature=0.7,
|
||||
reasoning_effort=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert any("toolUse" in block for msg in kwargs["messages"] for block in msg["content"])
|
||||
assert any("toolResult" in block for msg in kwargs["messages"] for block in msg["content"])
|
||||
assert kwargs["toolConfig"]["tools"][0]["toolSpec"]["name"] == "nanobot_noop"
|
||||
assert "toolChoice" not in kwargs["toolConfig"]
|
||||
|
||||
|
||||
def test_parse_response_maps_text_tools_reasoning_usage_and_stop_reason() -> None:
|
||||
response = {
|
||||
"output": {
|
||||
|
||||
@@ -847,6 +847,18 @@ def test_volcengine_thinking_enabled() -> None:
|
||||
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_volcengine_uses_max_completion_tokens() -> None:
|
||||
kw = _build_kwargs_for("volcengine", "doubao-seed-2-0-pro")
|
||||
assert kw["max_completion_tokens"] == 1024
|
||||
assert "max_tokens" not in kw
|
||||
|
||||
|
||||
def test_volcengine_coding_plan_uses_max_completion_tokens() -> None:
|
||||
kw = _build_kwargs_for("volcengine_coding_plan", "doubao-seed-2-0-pro")
|
||||
assert kw["max_completion_tokens"] == 1024
|
||||
assert "max_tokens" not in kw
|
||||
|
||||
|
||||
def test_byteplus_thinking_disabled_for_minimal() -> None:
|
||||
kw = _build_kwargs_for("byteplus", "doubao-seed-2-0-pro", reasoning_effort="minimal")
|
||||
assert kw["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for Xiaomi MiMo thinking-mode toggle via reasoning_effort.
|
||||
|
||||
The hosted Xiaomi MiMo API (api.xiaomimimo.com) accepts
|
||||
``{"thinking": {"type": "enabled"|"disabled"}}`` in the request body
|
||||
to toggle reasoning. Source: https://platform.xiaomimimo.com/docs/en-US/api/chat/openai-api
|
||||
|
||||
The thinking_type style already exists in _THINKING_STYLE_MAP and
|
||||
produces exactly this shape, so MiMo just needs to opt in via its
|
||||
ProviderSpec.thinking_style.
|
||||
|
||||
Default thinking behavior per Xiaomi docs:
|
||||
- mimo-v2-flash: disabled
|
||||
- mimo-v2.5-pro, mimo-v2.5, mimo-v2-pro, mimo-v2-omni: enabled
|
||||
|
||||
Without an explicit reasoning_effort, nanobot must not send the
|
||||
thinking field so the provider default is preserved (issue #3585).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.schema import ProvidersConfig
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
|
||||
def _mimo_spec():
|
||||
"""Return the registered xiaomi_mimo ProviderSpec."""
|
||||
specs = {s.name: s for s in PROVIDERS}
|
||||
return specs["xiaomi_mimo"]
|
||||
|
||||
|
||||
def _mimo_provider() -> OpenAICompatProvider:
|
||||
return OpenAICompatProvider(
|
||||
api_key="test-key",
|
||||
default_model="mimo-v2.5-pro",
|
||||
spec=_mimo_spec(),
|
||||
)
|
||||
|
||||
|
||||
def _simple_messages() -> list[dict[str, Any]]:
|
||||
return [{"role": "user", "content": "hello"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xiaomi_mimo_config_field_exists():
|
||||
"""ProvidersConfig should expose a xiaomi_mimo field."""
|
||||
config = ProvidersConfig()
|
||||
assert hasattr(config, "xiaomi_mimo")
|
||||
|
||||
|
||||
def test_xiaomi_mimo_uses_thinking_type_style():
|
||||
"""MiMo hosted API uses {"thinking": {"type": ...}}, the thinking_type style."""
|
||||
spec = _mimo_spec()
|
||||
assert spec.thinking_style == "thinking_type"
|
||||
assert spec.backend == "openai_compat"
|
||||
assert spec.default_api_base == "https://api.xiaomimimo.com/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_kwargs wire-format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mimo_reasoning_effort_none_disables_thinking():
|
||||
"""reasoning_effort="none" should send thinking.type="disabled"."""
|
||||
provider = _mimo_provider()
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=None, model=None, max_tokens=100,
|
||||
temperature=0.7, reasoning_effort="none", tool_choice=None,
|
||||
)
|
||||
# reasoning_effort itself must NOT be sent when value is "none"
|
||||
assert "reasoning_effort" not in kwargs
|
||||
# The disable signal must be in extra_body
|
||||
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
|
||||
|
||||
|
||||
def test_mimo_reasoning_effort_medium_enables_thinking():
|
||||
"""reasoning_effort="medium" should send thinking.type="enabled"."""
|
||||
provider = _mimo_provider()
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=None, model=None, max_tokens=100,
|
||||
temperature=0.7, reasoning_effort="medium", tool_choice=None,
|
||||
)
|
||||
assert kwargs.get("reasoning_effort") == "medium"
|
||||
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_mimo_reasoning_effort_low_enables_thinking():
|
||||
"""Any non-none/minimal effort enables thinking."""
|
||||
provider = _mimo_provider()
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=None, model=None, max_tokens=100,
|
||||
temperature=0.7, reasoning_effort="low", tool_choice=None,
|
||||
)
|
||||
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
|
||||
|
||||
|
||||
def test_mimo_reasoning_effort_unset_preserves_provider_default():
|
||||
"""When reasoning_effort is None, no thinking field is sent.
|
||||
|
||||
This preserves the provider default (varies by model per Xiaomi docs).
|
||||
Required so that omitting the config field behaves the same as before
|
||||
this fix — no behavior change for users who never set reasoning_effort.
|
||||
"""
|
||||
provider = _mimo_provider()
|
||||
kwargs = provider._build_kwargs(
|
||||
messages=_simple_messages(),
|
||||
tools=None, model=None, max_tokens=100,
|
||||
temperature=0.7, reasoning_effort=None, tool_choice=None,
|
||||
)
|
||||
assert "reasoning_effort" not in kwargs
|
||||
assert "extra_body" not in kwargs
|
||||
@@ -169,7 +169,7 @@ def test_init_prunes_stale_and_unsupported_conversation_refs(make_channel, tmp_p
|
||||
"conv-valid": {"updated_at": now - 60},
|
||||
"conv-webchat": {"updated_at": now - 60},
|
||||
"conv-group": {"updated_at": now - 60},
|
||||
"conv-stale": {"updated_at": now - msteams_module.MSTEAMS_REF_TTL_S - 1},
|
||||
"conv-stale": {"updated_at": now - 30 * 24 * 60 * 60 - 1},
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_from_config_default_path():
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
with patch("nanobot.config.loader.load_config") as mock_load, \
|
||||
patch("nanobot.nanobot._make_provider") as mock_prov:
|
||||
patch("nanobot.providers.factory.make_provider") as mock_prov:
|
||||
mock_load.return_value = Config()
|
||||
mock_prov.return_value = MagicMock()
|
||||
mock_prov.return_value.get_default_model.return_value = "test"
|
||||
@@ -127,7 +127,7 @@ def test_workspace_override(tmp_path):
|
||||
|
||||
def test_sdk_make_provider_uses_github_copilot_backend():
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.nanobot import _make_provider
|
||||
from nanobot.providers.factory import make_provider
|
||||
|
||||
config = Config.model_validate(
|
||||
{
|
||||
@@ -141,7 +141,7 @@ def test_sdk_make_provider_uses_github_copilot_backend():
|
||||
)
|
||||
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = _make_provider(config)
|
||||
provider = make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
@@ -23,14 +24,14 @@ async def test_message_tool_keeps_task_local_context() -> None:
|
||||
tool = MessageTool(send_callback=send_callback)
|
||||
|
||||
async def task_one() -> str:
|
||||
tool.set_context("feishu", "chat-a")
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a"))
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await tool.execute(content="one")
|
||||
|
||||
async def task_two() -> str:
|
||||
await entered.wait()
|
||||
tool.set_context("email", "chat-b")
|
||||
tool.set_context(RequestContext(channel="email", chat_id="chat-b"))
|
||||
release.set()
|
||||
return await tool.execute(content="two")
|
||||
|
||||
@@ -70,14 +71,14 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
|
||||
tool = SpawnTool(_Manager())
|
||||
|
||||
async def task_one() -> str:
|
||||
tool.set_context("whatsapp", "chat-a")
|
||||
tool.set_context(RequestContext(channel="whatsapp", chat_id="chat-a"))
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await tool.execute(task="one")
|
||||
|
||||
async def task_two() -> str:
|
||||
await entered.wait()
|
||||
tool.set_context("telegram", "chat-b")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-b"))
|
||||
release.set()
|
||||
return await tool.execute(task="two")
|
||||
|
||||
@@ -96,14 +97,14 @@ async def test_cron_tool_keeps_task_local_context(tmp_path) -> None:
|
||||
release = asyncio.Event()
|
||||
|
||||
async def task_one() -> str:
|
||||
tool.set_context("feishu", "chat-a")
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="chat-a"))
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await tool.execute(action="add", message="first", every_seconds=60)
|
||||
|
||||
async def task_two() -> str:
|
||||
await entered.wait()
|
||||
tool.set_context("email", "chat-b")
|
||||
tool.set_context(RequestContext(channel="email", chat_id="chat-b"))
|
||||
release.set()
|
||||
return await tool.execute(action="add", message="second", every_seconds=60)
|
||||
|
||||
@@ -129,7 +130,7 @@ async def test_message_tool_basic_set_context_and_execute() -> None:
|
||||
seen.append((msg.channel, msg.chat_id, msg.content))
|
||||
|
||||
tool = MessageTool(send_callback=send_callback)
|
||||
tool.set_context("telegram", "chat-123", "msg-456")
|
||||
tool.set_context(RequestContext(channel="telegram", chat_id="chat-123", message_id="msg-456"))
|
||||
|
||||
result = await tool.execute(content="hello")
|
||||
assert result == "Message sent to telegram:chat-123"
|
||||
@@ -180,7 +181,7 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
|
||||
return f"ok: {task}"
|
||||
|
||||
tool = SpawnTool(_Manager())
|
||||
tool.set_context("feishu", "chat-abc")
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="chat-abc"))
|
||||
|
||||
result = await tool.execute(task="do something")
|
||||
assert result == "ok: do something"
|
||||
@@ -221,7 +222,7 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
|
||||
async def test_cron_tool_basic_set_context_and_execute(tmp_path) -> None:
|
||||
"""Single task: set_context then add job should use correct target."""
|
||||
tool = CronTool(CronService(tmp_path / "jobs.json"))
|
||||
tool.set_context("wechat", "user-789")
|
||||
tool.set_context(RequestContext(channel="wechat", chat_id="user-789"))
|
||||
|
||||
result = await tool.execute(action="add", message="standup", every_seconds=300)
|
||||
assert result.startswith("Created job")
|
||||
|
||||
@@ -27,7 +27,7 @@ class TestBuildEnvUnix:
|
||||
def test_expected_keys(self):
|
||||
with patch("nanobot.agent.tools.shell._IS_WINDOWS", False):
|
||||
env = ExecTool()._build_env()
|
||||
expected = {"HOME", "LANG", "TERM"}
|
||||
expected = {"HOME", "LANG", "TERM", "PYTHONUNBUFFERED"}
|
||||
assert expected <= set(env)
|
||||
if sys.platform != "win32":
|
||||
assert set(env) == expected
|
||||
@@ -53,7 +53,7 @@ class TestBuildEnvWindows:
|
||||
|
||||
_EXPECTED_KEYS = {
|
||||
"SYSTEMROOT", "COMSPEC", "USERPROFILE", "HOMEDRIVE",
|
||||
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH",
|
||||
"HOMEPATH", "TEMP", "TMP", "PATHEXT", "PATH", "PYTHONUNBUFFERED",
|
||||
*_WINDOWS_ENV_KEYS,
|
||||
}
|
||||
|
||||
|
||||
@@ -83,13 +83,37 @@ async def test_message_tool_inherits_metadata_for_same_target() -> None:
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
slack_meta = {"slack": {"thread_ts": "111.222", "channel_type": "channel"}}
|
||||
tool.set_context("slack", "C123", metadata=slack_meta)
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata=slack_meta))
|
||||
|
||||
await tool.execute(content="thread reply")
|
||||
|
||||
assert sent[0].metadata == slack_meta
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_clears_metadata_when_context_has_none() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
|
||||
async def _send(msg: OutboundMessage) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
tool.set_context(
|
||||
RequestContext(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
|
||||
),
|
||||
)
|
||||
tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata={}))
|
||||
|
||||
await tool.execute(content="plain reply")
|
||||
|
||||
assert sent[0].metadata == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None:
|
||||
sent: list[OutboundMessage] = []
|
||||
@@ -98,10 +122,13 @@ async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None
|
||||
sent.append(msg)
|
||||
|
||||
tool = MessageTool(send_callback=_send)
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
tool.set_context(
|
||||
"slack",
|
||||
"C123",
|
||||
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
|
||||
RequestContext(
|
||||
channel="slack",
|
||||
chat_id="C123",
|
||||
metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}},
|
||||
),
|
||||
)
|
||||
|
||||
await tool.execute(content="channel reply", channel="slack", chat_id="C999")
|
||||
|
||||
@@ -156,7 +156,8 @@ class TestMessageToolTurnTracking:
|
||||
|
||||
def test_sent_in_turn_tracks_same_target(self) -> None:
|
||||
tool = MessageTool()
|
||||
tool.set_context("feishu", "chat1")
|
||||
from nanobot.agent.tools.context import RequestContext
|
||||
tool.set_context(RequestContext(channel="feishu", chat_id="chat1"))
|
||||
assert not tool._sent_in_turn
|
||||
tool._sent_in_turn = True
|
||||
assert tool._sent_in_turn
|
||||
|
||||
@@ -13,7 +13,24 @@ import pytest
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.agent.tools.search import GlobTool, GrepTool
|
||||
from nanobot.agent.tools.web import WebSearchTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import WebSearchConfig
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> None:
|
||||
tool = WebSearchTool(
|
||||
config=WebSearchConfig(provider="brave"),
|
||||
config_loader=lambda: WebSearchConfig(provider="duckduckgo", max_results=3),
|
||||
)
|
||||
|
||||
async def fake_duckduckgo(self, query: str, n: int) -> str:
|
||||
return f"{self.config.provider}:{query}:{n}"
|
||||
|
||||
monkeypatch.setattr(WebSearchTool, "_search_duckduckgo", fake_duckduckgo)
|
||||
|
||||
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -185,7 +202,7 @@ async def test_grep_files_with_matches_supports_head_limit_and_offset(tmp_path:
|
||||
# 2. The pagination info is correct
|
||||
assert "pagination: limit=1, offset=1" in result
|
||||
# Count non-empty lines that start with src/ (file paths)
|
||||
file_lines = [l for l in result.splitlines() if l.startswith("src/")]
|
||||
file_lines = [line for line in result.splitlines() if line.startswith("src/")]
|
||||
assert len(file_lines) == 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
"""Tests for tool plugin architecture: ToolLoader, ToolContext, metadata."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
class _MinimalTool(Tool):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "test_minimal"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "A test tool"
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
async def execute(self, **kwargs: Any) -> Any:
|
||||
return "ok"
|
||||
|
||||
|
||||
def test_tool_default_config_cls_is_none():
|
||||
assert _MinimalTool.config_cls() is None
|
||||
|
||||
|
||||
def test_tool_default_config_key_is_empty():
|
||||
assert _MinimalTool.config_key == ""
|
||||
|
||||
|
||||
def test_tool_default_enabled_is_true():
|
||||
assert _MinimalTool.enabled(None) is True
|
||||
|
||||
|
||||
def test_tool_default_create_returns_instance():
|
||||
tool = _MinimalTool.create(None)
|
||||
assert isinstance(tool, _MinimalTool)
|
||||
assert tool.name == "test_minimal"
|
||||
|
||||
|
||||
def test_tool_plugin_discoverable_default_is_true():
|
||||
assert _MinimalTool._plugin_discoverable is True
|
||||
|
||||
|
||||
# --- ToolContext tests ---
|
||||
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
|
||||
|
||||
def test_tool_context_has_required_fields():
|
||||
field_names = {f.name for f in fields(ToolContext)}
|
||||
required = {
|
||||
"config", "workspace", "bus", "subagent_manager",
|
||||
"cron_service", "file_state_store", "provider_snapshot_loader",
|
||||
"image_generation_provider_configs", "timezone",
|
||||
}
|
||||
assert required <= field_names
|
||||
|
||||
|
||||
def test_tool_context_defaults():
|
||||
ctx = ToolContext(config=None, workspace="/tmp")
|
||||
assert ctx.bus is None
|
||||
assert ctx.subagent_manager is None
|
||||
assert ctx.cron_service is None
|
||||
assert ctx.provider_snapshot_loader is None
|
||||
assert ctx.image_generation_provider_configs is None
|
||||
assert ctx.timezone == "UTC"
|
||||
|
||||
|
||||
# --- ToolLoader tests ---
|
||||
|
||||
from nanobot.agent.tools.loader import ToolLoader, _SKIP_MODULES
|
||||
|
||||
|
||||
def test_skip_modules_excludes_infrastructure():
|
||||
infra = {"base", "schema", "registry", "context", "loader", "config",
|
||||
"file_state", "sandbox", "mcp", "__init__"}
|
||||
assert infra <= _SKIP_MODULES
|
||||
|
||||
|
||||
def test_discover_finds_concrete_tools():
|
||||
loader = ToolLoader()
|
||||
discovered = loader.discover()
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "ExecTool" in class_names
|
||||
assert "MessageTool" in class_names
|
||||
assert "SpawnTool" in class_names
|
||||
|
||||
|
||||
def test_discover_excludes_abstract_and_mcp():
|
||||
loader = ToolLoader()
|
||||
discovered = loader.discover()
|
||||
class_names = {cls.__name__ for cls in discovered}
|
||||
assert "_FsTool" not in class_names
|
||||
assert "_SearchTool" not in class_names
|
||||
assert "MCPToolWrapper" not in class_names
|
||||
assert "MCPResourceWrapper" not in class_names
|
||||
assert "MCPPromptWrapper" not in class_names
|
||||
|
||||
|
||||
def test_discover_skips_private_classes():
|
||||
loader = ToolLoader()
|
||||
discovered = loader.discover()
|
||||
for cls in discovered:
|
||||
assert not cls.__name__.startswith("_")
|
||||
|
||||
|
||||
# --- Task 4: _FsTool.create() ---
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_fs_tool_create_builds_from_context():
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.restrict_to_workspace = False
|
||||
mock_config.exec.sandbox = ""
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp/test")
|
||||
tool = ReadFileTool.create(ctx)
|
||||
assert isinstance(tool, ReadFileTool)
|
||||
assert tool._workspace == Path("/tmp/test")
|
||||
|
||||
|
||||
def test_fs_tool_create_respects_restrict_to_workspace():
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.restrict_to_workspace = True
|
||||
mock_config.exec.sandbox = ""
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp/test")
|
||||
tool = ReadFileTool.create(ctx)
|
||||
assert tool._allowed_dir == Path("/tmp/test")
|
||||
|
||||
|
||||
def test_fs_tool_create_respects_sandbox():
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.restrict_to_workspace = False
|
||||
mock_config.exec.sandbox = "bwrap"
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp/test")
|
||||
tool = ReadFileTool.create(ctx)
|
||||
assert tool._allowed_dir == Path("/tmp/test")
|
||||
|
||||
|
||||
# --- Task 5: MessageTool, SpawnTool, CronTool ---
|
||||
|
||||
|
||||
async def test_message_tool_create():
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
mock_bus = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp", bus=mock_bus)
|
||||
tool = MessageTool.create(ctx)
|
||||
assert isinstance(tool, MessageTool)
|
||||
|
||||
|
||||
def test_spawn_tool_create():
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
mock_mgr = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp", subagent_manager=mock_mgr)
|
||||
tool = SpawnTool.create(ctx)
|
||||
assert isinstance(tool, SpawnTool)
|
||||
|
||||
|
||||
def test_cron_tool_enabled_without_service():
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
mock_config = MagicMock()
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp", cron_service=None)
|
||||
assert CronTool.enabled(ctx) is False
|
||||
|
||||
|
||||
def test_cron_tool_enabled_with_service():
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
mock_service = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp", cron_service=mock_service)
|
||||
assert CronTool.enabled(ctx) is True
|
||||
|
||||
|
||||
def test_cron_tool_create():
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
mock_service = MagicMock()
|
||||
mock_config = MagicMock()
|
||||
ctx = ToolContext(
|
||||
config=mock_config, workspace="/tmp",
|
||||
cron_service=mock_service, timezone="Asia/Shanghai",
|
||||
)
|
||||
tool = CronTool.create(ctx)
|
||||
assert isinstance(tool, CronTool)
|
||||
|
||||
|
||||
# --- Task 6: ExecTool, WebTools, ImageGenerationTool ---
|
||||
|
||||
|
||||
def test_exec_tool_config_cls():
|
||||
from nanobot.agent.tools.shell import ExecTool, ExecToolConfig
|
||||
assert ExecTool.config_cls() is ExecToolConfig
|
||||
assert ExecTool.config_key == "exec"
|
||||
|
||||
|
||||
def test_exec_tool_enabled():
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.exec.enable = True
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
assert ExecTool.enabled(ctx) is True
|
||||
mock_config.exec.enable = False
|
||||
assert ExecTool.enabled(ctx) is False
|
||||
|
||||
|
||||
def test_exec_tool_create():
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.exec.enable = True
|
||||
mock_config.exec.timeout = 120
|
||||
mock_config.exec.sandbox = ""
|
||||
mock_config.exec.path_append = ""
|
||||
mock_config.exec.allowed_env_keys = []
|
||||
mock_config.exec.allow_patterns = []
|
||||
mock_config.exec.deny_patterns = []
|
||||
mock_config.restrict_to_workspace = False
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
tool = ExecTool.create(ctx)
|
||||
assert isinstance(tool, ExecTool)
|
||||
|
||||
|
||||
def test_web_tools_config_cls():
|
||||
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool, WebToolsConfig
|
||||
assert WebSearchTool.config_key == "web"
|
||||
assert WebSearchTool.config_cls() is WebToolsConfig
|
||||
assert WebFetchTool.config_key == "web"
|
||||
assert WebFetchTool.config_cls() is WebToolsConfig
|
||||
|
||||
|
||||
def test_web_tools_enabled():
|
||||
from nanobot.agent.tools.web import WebSearchTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.web.enable = True
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
assert WebSearchTool.enabled(ctx) is True
|
||||
mock_config.web.enable = False
|
||||
assert WebSearchTool.enabled(ctx) is False
|
||||
|
||||
|
||||
def test_web_search_tool_create():
|
||||
from nanobot.agent.tools.web import WebSearchTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.web.enable = True
|
||||
mock_config.web.search = MagicMock()
|
||||
mock_config.web.proxy = None
|
||||
mock_config.web.user_agent = None
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
tool = WebSearchTool.create(ctx)
|
||||
assert isinstance(tool, WebSearchTool)
|
||||
|
||||
|
||||
def test_web_fetch_tool_create():
|
||||
from nanobot.agent.tools.web import WebFetchTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.web.enable = True
|
||||
mock_config.web.fetch = MagicMock()
|
||||
mock_config.web.proxy = None
|
||||
mock_config.web.user_agent = None
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
tool = WebFetchTool.create(ctx)
|
||||
assert isinstance(tool, WebFetchTool)
|
||||
|
||||
|
||||
def test_image_gen_tool_config_cls():
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationTool, ImageGenerationToolConfig
|
||||
assert ImageGenerationTool.config_key == "image_generation"
|
||||
assert ImageGenerationTool.config_cls() is ImageGenerationToolConfig
|
||||
|
||||
|
||||
def test_image_gen_tool_enabled():
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.image_generation.enabled = True
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
assert ImageGenerationTool.enabled(ctx) is True
|
||||
mock_config.image_generation.enabled = False
|
||||
assert ImageGenerationTool.enabled(ctx) is False
|
||||
|
||||
|
||||
def test_image_gen_tool_create():
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.image_generation = MagicMock()
|
||||
ctx = ToolContext(
|
||||
config=mock_config, workspace="/tmp",
|
||||
image_generation_provider_configs={"openrouter": MagicMock()},
|
||||
)
|
||||
tool = ImageGenerationTool.create(ctx)
|
||||
assert isinstance(tool, ImageGenerationTool)
|
||||
|
||||
|
||||
# --- Task 7: MyToolConfig + MCP wrappers ---
|
||||
|
||||
|
||||
def test_my_tool_config_cls():
|
||||
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
||||
assert MyTool.config_key == "my"
|
||||
assert MyTool.config_cls() is MyToolConfig
|
||||
|
||||
|
||||
def test_my_tool_enabled():
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
mock_config = MagicMock()
|
||||
mock_config.my.enable = True
|
||||
ctx = ToolContext(config=mock_config, workspace="/tmp")
|
||||
assert MyTool.enabled(ctx) is True
|
||||
mock_config.my.enable = False
|
||||
assert MyTool.enabled(ctx) is False
|
||||
|
||||
|
||||
def test_mcp_wrappers_not_discoverable():
|
||||
from nanobot.agent.tools.mcp import MCPToolWrapper, MCPResourceWrapper, MCPPromptWrapper
|
||||
assert MCPToolWrapper._plugin_discoverable is False
|
||||
assert MCPResourceWrapper._plugin_discoverable is False
|
||||
assert MCPPromptWrapper._plugin_discoverable is False
|
||||
|
||||
|
||||
# --- Task 8: Config round-trip tests ---
|
||||
|
||||
|
||||
def test_config_round_trip():
|
||||
"""Verify config serialization is unchanged after moving config classes."""
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_dict = {
|
||||
"tools": {
|
||||
"web": {"enable": True, "search": {"provider": "brave", "api_key": "test"}},
|
||||
"exec": {"enable": False, "timeout": 120},
|
||||
"my": {"allowSet": True},
|
||||
"imageGeneration": {"enabled": True, "provider": "openrouter"},
|
||||
}
|
||||
}
|
||||
config = Config.model_validate(config_dict)
|
||||
dumped = config.model_dump(mode="json", by_alias=True)
|
||||
|
||||
assert dumped["tools"]["my"]["allowSet"] is True
|
||||
assert dumped["tools"]["imageGeneration"]["enabled"] is True
|
||||
assert config.tools.exec.enable is False
|
||||
assert config.tools.exec.timeout == 120
|
||||
assert config.tools.web.search.provider == "brave"
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
"""Verify default values match the original hardcoded schema."""
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config = Config.model_validate({})
|
||||
assert config.tools.exec.enable is True
|
||||
assert config.tools.exec.timeout == 60
|
||||
assert config.tools.web.enable is True
|
||||
assert config.tools.web.search.provider == "duckduckgo"
|
||||
assert config.tools.my.enable is True
|
||||
assert config.tools.my.allow_set is False
|
||||
assert config.tools.image_generation.enabled is False
|
||||
assert config.tools.restrict_to_workspace is False
|
||||
|
||||
|
||||
# --- Task 10: Integration test ---
|
||||
|
||||
|
||||
def test_loader_registers_same_tools_as_old_hardcoded():
|
||||
"""Verify the loader produces the same tool set as the old _register_default_tools."""
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.exec.enable = True
|
||||
mock_config.exec.timeout = 60
|
||||
mock_config.exec.sandbox = ""
|
||||
mock_config.exec.path_append = ""
|
||||
mock_config.exec.allowed_env_keys = []
|
||||
mock_config.exec.allow_patterns = []
|
||||
mock_config.exec.deny_patterns = []
|
||||
mock_config.restrict_to_workspace = False
|
||||
mock_config.web.enable = True
|
||||
mock_config.web.search = MagicMock()
|
||||
mock_config.web.fetch = MagicMock()
|
||||
mock_config.web.proxy = None
|
||||
mock_config.web.user_agent = None
|
||||
mock_config.image_generation.enabled = False
|
||||
mock_config.my.enable = True
|
||||
|
||||
ctx = ToolContext(
|
||||
config=mock_config,
|
||||
workspace="/tmp",
|
||||
bus=MagicMock(),
|
||||
subagent_manager=MagicMock(),
|
||||
cron_service=MagicMock(),
|
||||
timezone="UTC",
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
loader = ToolLoader()
|
||||
registered = loader.load(ctx, registry)
|
||||
|
||||
expected = {
|
||||
"read_file", "write_file", "edit_file", "list_dir",
|
||||
"glob", "grep", "notebook_edit", "exec", "web_search", "web_fetch",
|
||||
"message", "spawn", "cron",
|
||||
}
|
||||
actual = set(registered)
|
||||
assert expected <= actual, f"Missing tools: {expected - actual}"
|
||||
Reference in New Issue
Block a user