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")