Compare commits

...

2 Commits

Author SHA1 Message Date
Hua
fd52973751 feat(config): hot reload agent runtime settings
Some checks failed
Test Suite / test (3.11) (push) Failing after 1m7s
Test Suite / test (3.12) (push) Failing after 1m3s
Test Suite / test (3.13) (push) Failing after 1m14s
2026-03-19 14:01:18 +08:00
Hua
cfcfb35f81 feat(mcp): add slash command listing 2026-03-19 13:10:07 +08:00
12 changed files with 610 additions and 11 deletions

View File

@@ -31,6 +31,8 @@ Do not commit real API keys, tokens, chat logs, or workspace data. Keep local se
- When a slash command changes user-visible wording, update both `nanobot/locales/en.json` and `nanobot/locales/zh.json`.
- If a slash command should appear in Telegram's native command menu, also update `nanobot/channels/telegram.py`.
- `/skill` currently supports `search`, `install`, `uninstall`, `list`, and `update`. Keep subcommand dispatch in `nanobot/agent/loop.py`.
- `/mcp` supports the default `list` behavior (and explicit `/mcp list`) to show configured MCP servers and registered MCP tools.
- Agent runtime config should be hot-reloaded from the active `config.json` for safe in-process fields such as `tools.mcpServers`, `tools.web.*`, `tools.exec.*`, `tools.restrictToWorkspace`, `agents.defaults.model`, `agents.defaults.maxToolIterations`, `agents.defaults.contextWindowTokens`, `agents.defaults.maxTokens`, `agents.defaults.temperature`, `agents.defaults.reasoningEffort`, `channels.sendProgress`, and `channels.sendToolHints`. Channel connection settings and provider credentials still require a restart.
- `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime.
- `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub.
- Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user.

View File

@@ -1187,6 +1187,7 @@ Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
```
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
nanobot hot-reloads agent runtime config from the active `config.json` on the next message, including `tools.mcpServers`, `tools.web.*`, `tools.exec.*`, `tools.restrictToWorkspace`, `agents.defaults.model`, `agents.defaults.maxToolIterations`, `agents.defaults.contextWindowTokens`, `agents.defaults.maxTokens`, `agents.defaults.temperature`, `agents.defaults.reasoningEffort`, `channels.sendProgress`, and `channels.sendToolHints`. Channel connection settings and provider credentials still require a restart.
@@ -1360,6 +1361,7 @@ These commands are available inside chats handled by `nanobot agent` or `nanobot
| `/skill uninstall <slug>` | Remove a ClawHub-managed skill from the active workspace |
| `/skill list` | List ClawHub-managed skills in the active workspace |
| `/skill update` | Update all ClawHub-managed skills in the active workspace |
| `/mcp [list]` | List configured MCP servers and registered MCP tools |
| `/stop` | Stop the current task |
| `/restart` | Restart the bot process |
| `/help` | Show command help |

View File

@@ -81,6 +81,7 @@ def help_lines(language: Any) -> list[str]:
text(active, "cmd_persona_list"),
text(active, "cmd_persona_set"),
text(active, "cmd_skill"),
text(active, "cmd_mcp"),
text(active, "cmd_stop"),
text(active, "cmd_restart"),
text(active, "cmd_help"),

View File

@@ -77,6 +77,7 @@ class AgentLoop:
bus: MessageBus,
provider: LLMProvider,
workspace: Path,
config_path: Path | None = None,
model: str | None = None,
max_iterations: int = 40,
context_window_tokens: int = 65_536,
@@ -97,6 +98,7 @@ class AgentLoop:
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.config_path = config_path
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.context_window_tokens = context_window_tokens
@@ -128,6 +130,9 @@ class AgentLoop:
self._running = False
self._mcp_servers = mcp_servers or {}
self._runtime_config_mtime_ns = (
config_path.stat().st_mtime_ns if config_path and config_path.exists() else None
)
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
@@ -191,6 +196,163 @@ class AgentLoop:
text(language, "cmd_lang_set"),
])
def _mcp_usage(self, language: str) -> str:
"""Return MCP command help text."""
return text(language, "mcp_usage")
def _group_mcp_tool_names(self) -> dict[str, list[str]]:
"""Group registered MCP tool names by configured server name."""
grouped = {name: [] for name in self._mcp_servers}
server_names = sorted(self._mcp_servers, key=len, reverse=True)
for tool_name in self.tools.tool_names:
if not tool_name.startswith("mcp_"):
continue
for server_name in server_names:
prefix = f"mcp_{server_name}_"
if tool_name.startswith(prefix):
grouped[server_name].append(tool_name.removeprefix(prefix))
break
return {name: sorted(tools) for name, tools in grouped.items()}
def _remove_registered_mcp_tools(self) -> None:
"""Remove all dynamically registered MCP tools from the registry."""
for tool_name in list(self.tools.tool_names):
if tool_name.startswith("mcp_"):
self.tools.unregister(tool_name)
@staticmethod
def _dump_mcp_servers(servers: dict) -> dict:
"""Normalize MCP server config for value-based comparisons."""
dumped = {}
for name, cfg in servers.items():
dumped[name] = cfg.model_dump() if hasattr(cfg, "model_dump") else cfg
return dumped
async def _reset_mcp_connections(self) -> None:
"""Drop MCP tool registrations and close active MCP connections."""
self._remove_registered_mcp_tools()
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass
self._mcp_stack = None
self._mcp_connected = False
self._mcp_connecting = False
def _apply_runtime_tool_config(self) -> None:
"""Apply runtime-configurable settings to already-registered tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
if read_tool := self.tools.get("read_file"):
read_tool._workspace = self.workspace
read_tool._allowed_dir = allowed_dir
read_tool._extra_allowed_dirs = extra_read
for name in ("write_file", "edit_file", "list_dir"):
if tool := self.tools.get(name):
tool._workspace = self.workspace
tool._allowed_dir = allowed_dir
tool._extra_allowed_dirs = None
if exec_tool := self.tools.get("exec"):
exec_tool.timeout = self.exec_config.timeout
exec_tool.working_dir = str(self.workspace)
exec_tool.restrict_to_workspace = self.restrict_to_workspace
exec_tool.path_append = self.exec_config.path_append
if web_search_tool := self.tools.get("web_search"):
web_search_tool._init_provider = self.web_search_provider
web_search_tool._init_api_key = self.brave_api_key
web_search_tool._init_base_url = self.web_search_base_url
web_search_tool.max_results = self.web_search_max_results
web_search_tool.proxy = self.web_proxy
if web_fetch_tool := self.tools.get("web_fetch"):
web_fetch_tool.proxy = self.web_proxy
def _apply_runtime_config(self, config) -> bool:
"""Apply hot-reloadable config to the current agent instance."""
from nanobot.providers.base import GenerationSettings
defaults = config.agents.defaults
tools_cfg = config.tools
web_cfg = tools_cfg.web
search_cfg = web_cfg.search
self.model = defaults.model
self.max_iterations = defaults.max_tool_iterations
self.context_window_tokens = defaults.context_window_tokens
self.exec_config = tools_cfg.exec
self.restrict_to_workspace = tools_cfg.restrict_to_workspace
self.brave_api_key = search_cfg.api_key or None
self.web_proxy = web_cfg.proxy or None
self.web_search_provider = search_cfg.provider
self.web_search_base_url = search_cfg.base_url or None
self.web_search_max_results = search_cfg.max_results
self.channels_config = config.channels
self.provider.generation = GenerationSettings(
temperature=defaults.temperature,
max_tokens=defaults.max_tokens,
reasoning_effort=defaults.reasoning_effort,
)
if hasattr(self.provider, "default_model"):
self.provider.default_model = self.model
self.memory_consolidator.model = self.model
self.memory_consolidator.context_window_tokens = self.context_window_tokens
self.subagents.apply_runtime_config(
model=self.model,
brave_api_key=self.brave_api_key,
web_proxy=self.web_proxy,
web_search_provider=self.web_search_provider,
web_search_base_url=self.web_search_base_url,
web_search_max_results=self.web_search_max_results,
exec_config=self.exec_config,
restrict_to_workspace=self.restrict_to_workspace,
)
self._apply_runtime_tool_config()
mcp_changed = self._dump_mcp_servers(config.tools.mcp_servers) != self._dump_mcp_servers(
self._mcp_servers
)
self._mcp_servers = config.tools.mcp_servers
return mcp_changed
async def _reload_runtime_config_if_needed(self, *, force: bool = False) -> None:
"""Reload hot-reloadable config from the active config file when it changes."""
if self.config_path is None:
return
try:
mtime_ns = self.config_path.stat().st_mtime_ns
except FileNotFoundError:
mtime_ns = None
if not force and mtime_ns == self._runtime_config_mtime_ns:
return
self._runtime_config_mtime_ns = mtime_ns
from nanobot.config.loader import load_config
if mtime_ns is None:
await self._reset_mcp_connections()
self._mcp_servers = {}
return
reloaded = load_config(self.config_path)
if self._apply_runtime_config(reloaded):
await self._reset_mcp_connections()
async def _reload_mcp_servers_if_needed(self, *, force: bool = False) -> None:
"""Backward-compatible wrapper for runtime config reloads."""
await self._reload_runtime_config_if_needed(force=force)
@staticmethod
def _decode_subprocess_output(data: bytes) -> str:
"""Decode subprocess output conservatively for CLI surfacing."""
@@ -363,6 +525,50 @@ class AgentLoop:
content = "\n\n".join(notes) if notes else text(language, "skill_command_completed", command=subcommand)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=content)
async def _handle_mcp_command(self, msg: InboundMessage, session: Session) -> OutboundMessage:
"""Handle MCP inspection commands."""
language = self._get_session_language(session)
parts = msg.content.strip().split()
if len(parts) > 1 and parts[1].lower() != "list":
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=self._mcp_usage(language),
)
await self._reload_mcp_servers_if_needed()
if not self._mcp_servers:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "mcp_no_servers"),
)
await self._connect_mcp()
server_lines = "\n".join(f"- {name}" for name in self._mcp_servers)
sections = [text(language, "mcp_servers_list", items=server_lines)]
grouped_tools = self._group_mcp_tool_names()
tool_lines = "\n".join(
f"- {server}: {', '.join(tools)}"
for server, tools in grouped_tools.items()
if tools
)
sections.append(
text(language, "mcp_tools_list", items=tool_lines)
if tool_lines
else text(language, "mcp_no_tools")
)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="\n\n".join(sections),
)
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
@@ -393,6 +599,7 @@ class AgentLoop:
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
await self._reload_mcp_servers_if_needed()
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
return
self._mcp_connecting = True
@@ -728,12 +935,7 @@ class AgentLoop:
if self._background_tasks:
await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._background_tasks.clear()
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
await self._reset_mcp_connections()
def _schedule_background(self, coro) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
@@ -753,6 +955,8 @@ class AgentLoop:
on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
await self._reload_runtime_config_if_needed()
# System messages: parse origin from chat_id ("channel:chat_id")
if msg.channel == "system":
channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
@@ -762,6 +966,7 @@ class AgentLoop:
session = self.sessions.get_or_create(key)
persona = self._get_session_persona(session)
language = self._get_session_language(session)
await self._connect_mcp()
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=0)
@@ -810,10 +1015,13 @@ class AgentLoop:
return await self._handle_persona_command(msg, session)
if cmd == "/skill":
return await self._handle_skill_command(msg, session)
if cmd == "/mcp":
return await self._handle_mcp_command(msg, session)
if cmd == "/help":
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)),
)
await self._connect_mcp()
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))

View File

@@ -52,6 +52,28 @@ class SubagentManager:
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
def apply_runtime_config(
self,
*,
model: str,
brave_api_key: str | None,
web_proxy: str | None,
web_search_provider: str,
web_search_base_url: str | None,
web_search_max_results: int,
exec_config: ExecToolConfig,
restrict_to_workspace: bool,
) -> None:
"""Update runtime-configurable settings for future subagent tasks."""
self.model = model
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.web_search_provider = web_search_provider
self.web_search_base_url = web_search_base_url
self.web_search_max_results = web_search_max_results
self.exec_config = exec_config
self.restrict_to_workspace = restrict_to_workspace
async def spawn(
self,
task: str,
@@ -209,7 +231,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
await self.bus.publish_inbound(msg)
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
def _build_subagent_prompt(self) -> str:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder

View File

@@ -118,7 +118,7 @@ class WebSearchTool(Tool):
return (
"Error: Brave Search API key not configured. Set it in "
"~/.nanobot/config.json under tools.web.search.apiKey "
"(or export BRAVE_API_KEY), then restart the gateway."
"(or export BRAVE_API_KEY), then retry your message."
)
try:

View File

@@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel):
name = "telegram"
display_name = "Telegram"
COMMAND_NAMES = ("start", "new", "lang", "persona", "skill", "stop", "help", "restart")
COMMAND_NAMES = ("start", "new", "lang", "persona", "skill", "mcp", "stop", "help", "restart")
@classmethod
def default_config(cls) -> dict[str, object]:
@@ -239,6 +239,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("lang", self._forward_command))
self._app.add_handler(CommandHandler("persona", self._forward_command))
self._app.add_handler(CommandHandler("skill", self._forward_command))
self._app.add_handler(CommandHandler("mcp", self._forward_command))
self._app.add_handler(CommandHandler("stop", self._forward_command))
self._app.add_handler(CommandHandler("restart", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))

View File

@@ -491,6 +491,7 @@ def gateway(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.config.loader import get_config_path
from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
@@ -520,6 +521,7 @@ def gateway(
bus=bus,
provider=provider,
workspace=config.workspace_path,
config_path=get_config_path(),
model=config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
context_window_tokens=config.agents.defaults.context_window_tokens,
@@ -683,6 +685,7 @@ def agent(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_config_path
from nanobot.config.paths import get_cron_dir
from nanobot.cron.service import CronService
@@ -706,6 +709,7 @@ def agent(
bus=bus,
provider=provider,
workspace=config.workspace_path,
config_path=get_config_path(),
model=config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
context_window_tokens=config.agents.defaults.context_window_tokens,

View File

@@ -13,6 +13,7 @@
"cmd_persona_list": "/persona list — List available personas",
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
"cmd_skill": "/skill <search|install|uninstall|list|update> ... — Manage ClawHub skills",
"cmd_mcp": "/mcp [list] — List configured MCP servers and registered tools",
"cmd_stop": "/stop — Stop the current task",
"cmd_restart": "/restart — Restart the bot",
"cmd_help": "/help — Show available commands",
@@ -27,6 +28,11 @@
"skill_command_network_failed": "ClawHub could not reach the npm registry. Check your network, proxy, or npm registry configuration and retry.",
"skill_command_completed": "ClawHub command completed: {command}",
"skill_applied_to_workspace": "Applied to workspace: {workspace}",
"mcp_usage": "Usage:\n/mcp\n/mcp list",
"mcp_no_servers": "No MCP servers are configured for this agent.",
"mcp_servers_list": "Configured MCP servers:\n{items}",
"mcp_tools_list": "Registered MCP tools:\n{items}",
"mcp_no_tools": "No MCP tools are currently registered. Check MCP server connectivity and configuration.",
"current_persona": "Current persona: {persona}",
"available_personas": "Available personas:\n{items}",
"unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.",
@@ -53,6 +59,7 @@
"lang": "Switch language",
"persona": "Show or switch personas",
"skill": "Search or install skills",
"mcp": "List MCP servers and tools",
"stop": "Stop the current task",
"help": "Show command help",
"restart": "Restart the bot"

View File

@@ -13,6 +13,7 @@
"cmd_persona_list": "/persona list — 查看可用人格",
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
"cmd_skill": "/skill <search|install|uninstall|list|update> ... — 管理 ClawHub skills",
"cmd_mcp": "/mcp [list] — 查看已配置的 MCP 服务和已注册工具",
"cmd_stop": "/stop — 停止当前任务",
"cmd_restart": "/restart — 重启机器人",
"cmd_help": "/help — 查看命令帮助",
@@ -27,6 +28,11 @@
"skill_command_network_failed": "ClawHub 无法连接到 npm registry。请检查网络、代理或 npm registry 配置后重试。",
"skill_command_completed": "ClawHub 命令执行完成:{command}",
"skill_applied_to_workspace": "已应用到工作区:{workspace}",
"mcp_usage": "用法:\n/mcp\n/mcp list",
"mcp_no_servers": "当前 agent 没有配置任何 MCP 服务。",
"mcp_servers_list": "已配置的 MCP 服务:\n{items}",
"mcp_tools_list": "已注册的 MCP 工具:\n{items}",
"mcp_no_tools": "当前没有已注册的 MCP 工具。请检查 MCP 服务连通性和配置。",
"current_persona": "当前人格:{persona}",
"available_personas": "可用人格:\n{items}",
"unknown_persona": "未知人格:{name}\n可用人格{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。",
@@ -53,6 +59,7 @@
"lang": "切换语言",
"persona": "查看或切换人格",
"skill": "搜索或安装技能",
"mcp": "查看 MCP 服务和工具",
"stop": "停止当前任务",
"help": "查看命令帮助",
"restart": "重启机器人"

340
tests/test_mcp_commands.py Normal file
View File

@@ -0,0 +1,340 @@
"""Tests for /mcp slash command integration."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.bus.events import InboundMessage
class _FakeTool:
def __init__(self, name: str) -> None:
self._name = name
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._name
@property
def parameters(self) -> dict:
return {"type": "object", "properties": {}}
async def execute(self, **kwargs) -> str:
return ""
def _make_loop(workspace: Path, *, mcp_servers: dict | None = None, config_path: Path | None = None):
"""Create an AgentLoop with a real workspace and lightweight mocks."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
with patch("nanobot.agent.loop.SubagentManager"):
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=workspace,
config_path=config_path,
mcp_servers=mcp_servers,
)
return loop
@pytest.mark.asyncio
async def test_mcp_lists_configured_servers_and_tools(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, mcp_servers={"docs": object(), "search": object()})
loop.tools.register(_FakeTool("mcp_docs_lookup"))
loop.tools.register(_FakeTool("mcp_search_web"))
loop.tools.register(_FakeTool("read_file"))
with patch.object(loop, "_connect_mcp", AsyncMock()) as connect_mcp:
response = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/mcp")
)
assert response is not None
assert "Configured MCP servers:" in response.content
assert "- docs" in response.content
assert "- search" in response.content
assert "docs: lookup" in response.content
assert "search: web" in response.content
connect_mcp.assert_awaited_once()
@pytest.mark.asyncio
async def test_mcp_without_servers_returns_guidance(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
response = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/mcp list")
)
assert response is not None
assert response.content == "No MCP servers are configured for this agent."
@pytest.mark.asyncio
async def test_help_includes_mcp_command(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
response = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/help")
)
assert response is not None
assert "/mcp [list]" in response.content
@pytest.mark.asyncio
async def test_mcp_command_hot_reloads_servers_from_config(tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
loop = _make_loop(tmp_path, mcp_servers={}, config_path=config_path)
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
"docs": {
"command": "npx",
"args": ["-y", "@demo/docs"],
}
}
}
}
),
encoding="utf-8",
)
with patch.object(loop, "_connect_mcp", AsyncMock()) as connect_mcp:
response = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/mcp")
)
assert response is not None
assert "Configured MCP servers:" in response.content
assert "- docs" in response.content
connect_mcp.assert_awaited_once()
@pytest.mark.asyncio
async def test_mcp_config_reload_resets_connections_and_tools(tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
"old": {
"command": "npx",
"args": ["-y", "@demo/old"],
}
}
}
}
),
encoding="utf-8",
)
loop = _make_loop(
tmp_path,
mcp_servers={"old": SimpleNamespace(model_dump=lambda: {"command": "npx", "args": ["-y", "@demo/old"]})},
config_path=config_path,
)
stack = SimpleNamespace(aclose=AsyncMock())
loop._mcp_stack = stack
loop._mcp_connected = True
loop.tools.register(_FakeTool("mcp_old_lookup"))
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
"new": {
"command": "npx",
"args": ["-y", "@demo/new"],
}
}
}
}
),
encoding="utf-8",
)
await loop._reload_mcp_servers_if_needed(force=True)
assert list(loop._mcp_servers) == ["new"]
assert loop._mcp_connected is False
assert loop.tools.get("mcp_old_lookup") is None
stack.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_regular_messages_pick_up_reloaded_mcp_config(tmp_path: Path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
loop = _make_loop(tmp_path, mcp_servers={}, config_path=config_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=SimpleNamespace(
has_tool_calls=False,
content="ok",
finish_reason="stop",
reasoning_content=None,
thinking_blocks=None,
)
)
config_path.write_text(
json.dumps(
{
"tools": {
"mcpServers": {
"docs": {
"command": "npx",
"args": ["-y", "@demo/docs"],
}
}
}
}
),
encoding="utf-8",
)
connect_mcp_servers = AsyncMock()
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", connect_mcp_servers)
response = await loop._process_message(
InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="hello")
)
assert response is not None
assert response.content == "ok"
assert list(loop._mcp_servers) == ["docs"]
connect_mcp_servers.assert_awaited_once()
@pytest.mark.asyncio
async def test_runtime_config_reload_updates_agent_and_tool_settings(tmp_path: Path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "initial-model",
"maxToolIterations": 4,
"contextWindowTokens": 4096,
"maxTokens": 1000,
"temperature": 0.2,
"reasoningEffort": "low",
}
},
"tools": {
"restrictToWorkspace": False,
"exec": {"timeout": 20, "pathAppend": ""},
"web": {
"proxy": "",
"search": {
"provider": "brave",
"apiKey": "",
"baseUrl": "",
"maxResults": 3,
}
},
},
"channels": {
"sendProgress": True,
"sendToolHints": False,
},
}
),
encoding="utf-8",
)
loop = _make_loop(tmp_path, mcp_servers={}, config_path=config_path)
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "reloaded-model",
"maxToolIterations": 9,
"contextWindowTokens": 8192,
"maxTokens": 2222,
"temperature": 0.7,
"reasoningEffort": "high",
}
},
"tools": {
"restrictToWorkspace": True,
"exec": {"timeout": 45, "pathAppend": "/usr/local/bin"},
"web": {
"proxy": "http://127.0.0.1:7890",
"search": {
"provider": "searxng",
"apiKey": "demo-key",
"baseUrl": "https://search.example.com",
"maxResults": 7,
}
},
},
"channels": {
"sendProgress": False,
"sendToolHints": True,
},
}
),
encoding="utf-8",
)
await loop._reload_runtime_config_if_needed(force=True)
exec_tool = loop.tools.get("exec")
web_search_tool = loop.tools.get("web_search")
web_fetch_tool = loop.tools.get("web_fetch")
read_tool = loop.tools.get("read_file")
assert loop.model == "reloaded-model"
assert loop.max_iterations == 9
assert loop.context_window_tokens == 8192
assert loop.provider.generation.max_tokens == 2222
assert loop.provider.generation.temperature == 0.7
assert loop.provider.generation.reasoning_effort == "high"
assert loop.memory_consolidator.model == "reloaded-model"
assert loop.memory_consolidator.context_window_tokens == 8192
assert loop.channels_config.send_progress is False
assert loop.channels_config.send_tool_hints is True
loop.subagents.apply_runtime_config.assert_called_once_with(
model="reloaded-model",
brave_api_key="demo-key",
web_proxy="http://127.0.0.1:7890",
web_search_provider="searxng",
web_search_base_url="https://search.example.com",
web_search_max_results=7,
exec_config=loop.exec_config,
restrict_to_workspace=True,
)
assert exec_tool.timeout == 45
assert exec_tool.path_append == "/usr/local/bin"
assert exec_tool.restrict_to_workspace is True
assert web_search_tool._init_provider == "searxng"
assert web_search_tool._init_api_key == "demo-key"
assert web_search_tool._init_base_url == "https://search.example.com"
assert web_search_tool.max_results == 7
assert web_search_tool.proxy == "http://127.0.0.1:7890"
assert web_fetch_tool.proxy == "http://127.0.0.1:7890"
assert read_tool._allowed_dir == tmp_path

View File

@@ -1,5 +1,3 @@
import asyncio
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -206,6 +204,13 @@ def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None:
assert channel.is_allowed("not-a-number|alice") is False
def test_build_bot_commands_includes_mcp() -> None:
commands = TelegramChannel._build_bot_commands("en")
descriptions = {command.command: command.description for command in commands}
assert descriptions["mcp"] == "List MCP servers and tools"
@pytest.mark.asyncio
async def test_send_progress_keeps_message_in_topic() -> None:
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])