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:
chengyongru
2026-05-12 11:28:20 +08:00
committed by Xubin Ren
parent bd0ba745dd
commit 043f0e67f7
40 changed files with 1404 additions and 378 deletions
+23
View File
@@ -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)
+19
View File
@@ -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
+10 -11
View File
@@ -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
+30
View File
@@ -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")
+8 -6
View File
@@ -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
+77
View File
@@ -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")
+34 -34
View File
@@ -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)
+6 -3
View File
@@ -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")
+9 -6
View File
@@ -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
+2 -1
View File
@@ -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
+10 -9
View File
@@ -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")
+2 -2
View File
@@ -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,
}
+8 -4
View File
@@ -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")
+2 -1
View File
@@ -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
+413
View File
@@ -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}"