"""Hot-reload all tools without restarting the bot."""
from __future__ import annotations
import asyncio
import jsonutil as json
import logging
from tools import ToolRegistry
from tools.alter_privileges import PRIVILEGES, has_privilege
logger = logging.getLogger(__name__)
TOOL_NAME = "reload_tools"
TOOL_DESCRIPTION = (
"Reload all tools registered with the bot's tool system. "
"Useful when tools have been added or modified at runtime."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {},
}
[docs]
async def run(ctx=None, **_kwargs) -> str:
"""Execute this tool and return the result.
Args:
ctx: Tool execution context providing access to bot internals.
Returns:
str: Result string.
"""
try:
registry = getattr(ctx, "tool_registry", None) if ctx else None
config = getattr(ctx, "config", None) if ctx else None
if registry is None:
return json.dumps(
{"success": False, "error": "Tool registry not available via ctx"}
)
if config is None:
return json.dumps(
{"success": False, "error": "Config not available via ctx"}
)
user_id = getattr(ctx, "user_id", "") or ""
redis = getattr(ctx, "redis", None)
if not await has_privilege(
redis, user_id, PRIVILEGES["UNSANDBOXED_EXEC"], config
):
return json.dumps(
{
"success": False,
"error": (
"reload_tools requires UNSANDBOXED_EXEC. "
"Ask an admin to grant it with alter_privileges."
),
}
)
from tool_loader import load_tools
tm = registry.task_manager
def _load_into_new_registry() -> ToolRegistry:
"""Build a fresh ``ToolRegistry`` and load all tools into it.
Synchronous closure executed off the event loop (via
``asyncio.to_thread`` in the enclosing :func:`run`) because
``load_tools`` performs blocking module import and exec. It
constructs a new registry of the same concrete type as the active
one, wiring in the captured ``task_manager`` (``tm``), then calls
``load_tools`` from ``tool_loader`` to discover and register every
tool under ``config.tools_dir``. The newly built registry is
returned to the caller, which atomically swaps its ``_tools`` into
the live registry; this closure itself performs no Redis, KG, LLM,
or HTTP work and does not mutate the existing registry.
Returns:
ToolRegistry: A freshly populated registry instance ready to
replace the live one's tool table.
Defined inline within :func:`run` and invoked only there; it has no
other internal callers.
"""
fresh = type(registry)(task_manager=tm)
load_tools(config.tools_dir, fresh)
return fresh
# load_tools does sync import/exec — keep it off the event loop.
new_registry = await asyncio.to_thread(_load_into_new_registry)
with registry._lock:
old_count = len(registry._tools)
old_permissions = dict(registry._permissions)
registry._tools = new_registry._tools
registry.invalidate_cache()
registry._permissions = old_permissions
new_count = len(registry._tools)
logger.info("Tool reload completed: %d -> %d tools", old_count, new_count)
return json.dumps(
{
"success": True,
"old_count": old_count,
"new_count": new_count,
"message": f"Tools reloaded successfully: {old_count} -> {new_count} tools",
}
)
except Exception as e:
logger.error("Error reloading tools: %s", e, exc_info=True)
return json.dumps({"success": False, "error": f"Error reloading tools: {e}"})