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
+30 -50
View File
@@ -12,15 +12,13 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.search import GlobTool, GrepTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ExecToolConfig, WebToolsConfig
from nanobot.config.schema import AgentDefaults, ToolsConfig
from nanobot.providers.base import LLMProvider
from nanobot.utils.prompt_templates import render_template
@@ -77,8 +75,7 @@ class SubagentManager:
bus: MessageBus,
max_tool_result_chars: int,
model: str | None = None,
web_config: "WebToolsConfig | None" = None,
exec_config: "ExecToolConfig | None" = None,
tools_config: ToolsConfig | None = None,
restrict_to_workspace: bool = False,
disabled_skills: list[str] | None = None,
max_iterations: int | None = None,
@@ -88,9 +85,8 @@ class SubagentManager:
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
self.web_config = web_config or WebToolsConfig()
self.tools_config = tools_config or ToolsConfig()
self.max_tool_result_chars = max_tool_result_chars
self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace
self.disabled_skills = set(disabled_skills or [])
self.max_iterations = (
@@ -103,6 +99,29 @@ class SubagentManager:
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
self._tools_cache: ToolRegistry | None = None
def _subagent_tools_config(self) -> ToolsConfig:
"""Build a ToolsConfig scoped for subagent use."""
return ToolsConfig(
exec=self.tools_config.exec,
web=self.tools_config.web,
restrict_to_workspace=self.restrict_to_workspace,
)
def _build_tools(self) -> ToolRegistry:
"""Build the subagent tool registry via ToolLoader (cached)."""
if self._tools_cache is not None:
return self._tools_cache
registry = ToolRegistry()
ctx = ToolContext(
config=self._subagent_tools_config(),
workspace=str(self.workspace),
file_state_store=FileStates(),
)
ToolLoader().load(ctx, registry, scope="subagent")
self._tools_cache = registry
return registry
def set_provider(self, provider: LLMProvider, model: str) -> None:
self.provider = provider
@@ -168,46 +187,7 @@ class SubagentManager:
status.iteration = payload.get("iteration", status.iteration)
try:
# Build subagent tools (no message tool, no spawn tool)
tools = ToolRegistry()
allowed_dir = self.workspace if (self.restrict_to_workspace or self.exec_config.sandbox) else None
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
# Subagent gets its own FileStates so its read-dedup cache is
# isolated from the parent loop's sessions (issue #3571).
from nanobot.agent.tools.file_state import FileStates
file_states = FileStates()
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read, file_states=file_states))
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
tools.register(GlobTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
tools.register(GrepTool(workspace=self.workspace, allowed_dir=allowed_dir, file_states=file_states))
if self.exec_config.enable:
tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
sandbox=self.exec_config.sandbox,
path_append=self.exec_config.path_append,
allowed_env_keys=self.exec_config.allowed_env_keys,
allow_patterns=self.exec_config.allow_patterns,
deny_patterns=self.exec_config.deny_patterns,
))
if self.web_config.enable:
tools.register(
WebSearchTool(
config=self.web_config.search,
proxy=self.web_config.proxy,
user_agent=self.web_config.user_agent,
)
)
tools.register(
WebFetchTool(
config=self.web_config.fetch,
proxy=self.web_config.proxy,
user_agent=self.web_config.user_agent,
)
)
tools = self._build_tools()
system_prompt = self._build_subagent_prompt()
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},