diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 694bd8c43..3d51b376e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -41,7 +41,6 @@ from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, res from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.runtime_control import AgentRuntimeControl -from nanobot.agent.tools.self import MyTool from nanobot.agent.turn_delivery import ( TurnDelivery, TurnDeliveryFactory, @@ -644,20 +643,11 @@ class AgentLoop: timezone=self.context.timezone or "UTC", workspace_sandbox=self.workspace_scopes.sandbox_status, runtime_events=self.runtime_events, + runtime_control=AgentRuntimeControl(self), ) loader = ToolLoader() registered = loader.load(ctx, self.tools) - # MyTool receives only the explicit runtime-control capability. - if self.tools_config.my.enable: - self.tools.register( - MyTool( - runtime_control=AgentRuntimeControl(self), - modify_allowed=self.tools_config.my.allow_set, - ) - ) - registered.append("my") - logger.info("Registered {} tools: {}", len(registered), registered) def register_runtime_context_provider( diff --git a/nanobot/agent/tools/context.py b/nanobot/agent/tools/context.py index 1db6a3dd3..d1cb46c38 100644 --- a/nanobot/agent/tools/context.py +++ b/nanobot/agent/tools/context.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from nanobot.agent.subagent import SubagentManager from nanobot.agent.tools.exec_session import ExecSessionManager from nanobot.agent.tools.file_state import FileStates + from nanobot.agent.tools.runtime_control import RuntimeControl from nanobot.bus.queue import MessageBus from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.config.schema import ProviderConfig, ToolsConfig @@ -90,3 +91,4 @@ class ToolContext: timezone: str = "UTC" workspace_sandbox: WorkspaceSandboxStatus | None = None runtime_events: RuntimeEventBus | None = None + runtime_control: RuntimeControl | None = None diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index cbac4315e..005ad1be0 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -58,7 +58,6 @@ def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]: class MyTool(Tool): """Check and set the agent loop's runtime configuration.""" - _plugin_discoverable = False # Requires AgentLoop reference; registered manually config_key = "my" @classmethod @@ -67,7 +66,16 @@ class MyTool(Tool): @classmethod def enabled(cls, ctx: ToolContext) -> bool: - return ctx.config.my.enable + return ctx.runtime_control is not None and ctx.config.my.enable + + @classmethod + def create(cls, ctx: ToolContext) -> Tool: + if ctx.runtime_control is None: + raise RuntimeError("MyTool requires a runtime control capability") + return cls( + runtime_control=ctx.runtime_control, + modify_allowed=ctx.config.my.allow_set, + ) BLOCKED = frozenset({ # Core infrastructure diff --git a/tests/tools/test_tool_loader.py b/tests/tools/test_tool_loader.py index c6f3de044..90040103c 100644 --- a/tests/tools/test_tool_loader.py +++ b/tests/tools/test_tool_loader.py @@ -59,6 +59,7 @@ def test_tool_context_has_required_fields(): "config", "workspace", "bus", "subagent_manager", "cron_service", "exec_session_manager", "file_state_store", "provider_snapshot_loader", "image_generation_provider_configs", "timezone", + "runtime_control", } assert required <= field_names @@ -71,6 +72,7 @@ def test_tool_context_defaults(): assert ctx.exec_session_manager is None assert ctx.provider_snapshot_loader is None assert ctx.image_generation_provider_configs is None + assert ctx.runtime_control is None assert ctx.timezone == "UTC" @@ -91,6 +93,7 @@ def test_discover_finds_concrete_tools(): assert "ExecTool" in class_names assert "CliAppsTool" in class_names assert "MessageTool" in class_names + assert "MyTool" in class_names assert "SpawnTool" in class_names assert "ExecSessionTool" in class_names @@ -373,12 +376,26 @@ 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") + ctx = ToolContext( + config=mock_config, + workspace="/tmp", + runtime_control=MagicMock(), + ) assert MyTool.enabled(ctx) is True mock_config.my.enable = False assert MyTool.enabled(ctx) is False +def test_my_tool_requires_runtime_control(): + 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 False + + def test_mcp_wrappers_not_discoverable(): from nanobot.agent.tools.mcp import MCPPromptWrapper, MCPResourceWrapper, MCPToolWrapper assert MCPToolWrapper._plugin_discoverable is False @@ -411,6 +428,7 @@ def test_loader_registers_same_tools_as_old_hardcoded(): mock_config.web.user_agent = None mock_config.image_generation.enabled = False mock_config.my.enable = True + mock_config.my.allow_set = False ctx = ToolContext( config=mock_config, @@ -419,6 +437,7 @@ def test_loader_registers_same_tools_as_old_hardcoded(): subagent_manager=MagicMock(), cron_service=MagicMock(), timezone="UTC", + runtime_control=MagicMock(), ) registry = ToolRegistry() loader = ToolLoader() @@ -429,6 +448,7 @@ def test_loader_registers_same_tools_as_old_hardcoded(): "find_files", "grep", "exec", "exec_session", "list_exec_sessions", "web_search", "web_fetch", "message", "spawn", "cron", + "my", } actual = set(registered) assert expected <= actual, f"Missing tools: {expected - actual}"