Newer
Older
navi-1 / navi / core / reload.py
"""Hot-reload of user tools, context providers and MCP servers.

One implementation serves both surfaces — the ``reload_tools`` built-in and the
admin endpoint behind the settings button — so the two can never drift apart.
The MCP half especially: ``register_mcp_tools`` only ever *adds* registrations,
so reconnecting without dropping the old ones left ghost tools for every server
removed from the config or every tool the server stopped exposing.
"""

from dataclasses import dataclass, field

import structlog

from navi.config import settings

log = structlog.get_logger()


@dataclass
class ReloadReport:
    """What a reload loaded, and everything that went wrong on the way."""

    tools: list[str] = field(default_factory=list)
    total_tools: int = 0
    tool_errors: dict[str, str] = field(default_factory=dict)
    missing_enabled: list[str] = field(default_factory=list)
    providers: list[str] = field(default_factory=list)
    provider_errors: dict[str, str] = field(default_factory=dict)
    mcp_tools: list[str] = field(default_factory=list)
    mcp_error: str | None = None

    @property
    def ok(self) -> bool:
        """True when nothing failed to load. ``missing_enabled`` is a warning:
        the name in enabled.json is wrong, but the reload itself worked."""
        return not self.tool_errors and not self.provider_errors and self.mcp_error is None

    def as_dict(self) -> dict:
        return {
            "ok": self.ok,
            "tools": self.tools,
            "total_tools": self.total_tools,
            "tool_errors": self.tool_errors,
            "missing_enabled": self.missing_enabled,
            "providers": self.providers,
            "provider_errors": self.provider_errors,
            "mcp_tools": self.mcp_tools,
            "mcp_error": self.mcp_error,
        }

    def lines(self) -> list[str]:
        """Human/model-readable report, one line per fact."""
        lines = [
            f"Tools ({len(self.tools)}): {', '.join(self.tools)}."
            if self.tools
            else "Tools: none.",
        ]
        if self.tool_errors:
            lines.append(f"Tool errors ({len(self.tool_errors)}):")
            lines.extend(f"  {f}: {e}" for f, e in self.tool_errors.items())
        if self.missing_enabled:
            lines.append(
                f"Warning: enabled.json names {len(self.missing_enabled)} tool(s) that do not "
                f"exist and are silently dropped from every profile: "
                f"{', '.join(self.missing_enabled)}."
            )
        lines.append(
            f"Context providers ({len(self.providers)}): {', '.join(self.providers)}."
            if self.providers
            else "Context providers: none."
        )
        if self.provider_errors:
            lines.append(f"Context provider errors ({len(self.provider_errors)}):")
            lines.extend(f"  {f}: {e}" for f, e in self.provider_errors.items())
        if self.mcp_error is not None:
            lines.append(f"MCP reload error: {self.mcp_error}")
        else:
            lines.append(
                f"MCP tools ({len(self.mcp_tools)}): {', '.join(self.mcp_tools)}."
                if self.mcp_tools
                else "MCP tools: none."
            )
        return lines


def _missing_enabled(registry) -> list[str]:
    """Names in tools/enabled.json that no tool in the registry answers to."""
    from navi.core.tool_utils import load_user_enabled_tools

    missing = []
    for name in load_user_enabled_tools():
        try:
            registry.get(name)
        except Exception:
            missing.append(name)
    return missing


async def _reload_mcp(registry, mcp_manager) -> tuple[list[str], str | None]:
    """Reconnect every MCP server and rebuild its tool registrations.

    Every external registration is dropped first — see the module docstring.
    Servers that are down right now lose their tools until the health-check
    reconnects them, which re-registers them through the manager's
    on_server_connected callback.
    """
    from navi.api.deps import register_mcp_tools
    from navi.mcp.tools import is_mcp_tool

    registry.clear_external_tools()
    await mcp_manager.reload_all()
    await register_mcp_tools(registry, mcp_manager)
    return sorted(t.name for t in registry.all() if is_mcp_tool(t.name)), None


async def reload_all(registry, cp_registry=None, mcp_manager=None) -> ReloadReport:
    """Reload user tools, context providers and MCP servers in place."""
    report = ReloadReport()

    if registry is not None:
        tool_result = registry.reload_user_tools(settings.tools_dir)
        report.tools = [t.name for t in tool_result.loaded]
        report.tool_errors = dict(tool_result.errors)
        report.total_tools = len(registry.all())
        report.missing_enabled = _missing_enabled(registry)

    if cp_registry is not None:
        cp_result = cp_registry.reload_user_providers(settings.context_providers_dir)
        report.providers = list(cp_result.loaded)
        report.provider_errors = dict(cp_result.errors)

    if mcp_manager is not None and registry is not None:
        try:
            report.mcp_tools, report.mcp_error = await _reload_mcp(registry, mcp_manager)
        except Exception as exc:  # noqa: BLE001 — a failed MCP pass must not lose the rest
            log.warning("reload.mcp_failed", error=str(exc))
            report.mcp_error = f"{type(exc).__name__}: {exc}"

    log.info(
        "reload.done",
        tools=len(report.tools),
        tool_errors=len(report.tool_errors),
        missing_enabled=len(report.missing_enabled),
        providers=len(report.providers),
        mcp_tools=len(report.mcp_tools),
        ok=report.ok,
    )
    return report