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")
|
||||
|
||||
Reference in New Issue
Block a user