feat(tools): introduce plugin-based tool discovery and runtime context protocol
This commit implements a progressive refactoring of the tool system to support plugin discovery, scoped loading, and protocol-driven runtime context injection. Key changes: - Add Tool ABC metadata (tool_name, _scopes) and ToolContext dataclass for dependency injection. - Introduce ToolLoader with pkgutil-based builtin discovery and entry_points-based third-party plugin loading. - Add scope filtering (core/subagent/memory) so different contexts load appropriate tool sets. - Introduce ContextAware protocol and RequestContext dataclass to replace hardcoded per-tool context injection in AgentLoop. - Add RuntimeState / MutableRuntimeState protocols to decouple MyTool from AgentLoop. - Migrate all built-in tools to declare scopes and implement create()/enabled() hooks. - Migrate MessageTool, SpawnTool, CronTool, and MyTool to ContextAware. - Refactor AgentLoop to use ToolLoader and protocol-driven context injection. - Refactor SubagentManager to use ToolLoader(scope="subagent") with per-run FileStates isolation. - Register all built-in tools via pyproject.toml entry_points. - Add comprehensive tests for loader scopes, entry_points, ContextAware, subagent tools, and runtime state sync.
This commit is contained in:
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +83,8 @@ 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")
|
||||
|
||||
@@ -98,10 +99,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
|
||||
|
||||
@@ -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 = {
|
||||
"ask_user", "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