"""Built-in tool that returns the detailed manual for a given tool.
Two ways of answering: a hand-written ``manuals/<tool>.md`` when one exists, and
a manual generated from the tool's live schema otherwise. Resolution goes through
the same ``resolve_tool`` the executor uses, so a name that works when the agent
*calls* the tool also works when it *asks about* it — the two used to disagree and
a manual the file system held was unreachable under its MCP spelling.
"""
from __future__ import annotations
import difflib
from pathlib import Path
from ._internal.base import Tool, ToolContext, ToolResult, current_profile_id
MANUALS_DIR = Path(__file__).parent.parent.parent / "manuals"
GUIDES_DIR = MANUALS_DIR / "guides"
# Same spelling list_tools buckets by, so the two agree on where a tool comes from.
NATIVE_SOURCE = "native"
_MCP_PREFIX = "mcp__"
_MCP_SEP = "__"
_MAX_SUGGESTIONS = 5
_MAX_DEPTH = 5
class ToolManualTool(Tool):
name = "tool_manual"
description = (
"Returns the detailed manual for a tool: full usage instructions, parameter "
"reference, and examples. Call this before using an unfamiliar tool, or when "
"you are unsure about the correct format or parameters. Name the tool bare "
"('compile_scad') or by its full MCP name ('mcp__navi-3d__compile_scad'); name "
"an MCP server ('gntodo') to get that server's instructions and tool list. "
"With no name it lists which docs exist."
)
parameters = {
"type": "object",
"properties": {
"tool_name": {
"type": "string",
"description": (
"Tool to look up — 'filesystem', 'todo', 'mcp__navi-3d__compile_scad' "
"— or the name of an MCP server, e.g. 'gntodo'. Omit to list what "
"manuals exist."
),
}
},
}
def __init__(self, registry=None, profile_registry=None, mcp_manager=None) -> None:
self._registry = registry
self._profile_registry = profile_registry
self._mcp_manager = mcp_manager
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
# Deferred: navi.core builds the registry, which imports this module.
from navi.core.tool_utils import resolve_tool
requested = params.get("tool_name")
# params["tool_name"] raised KeyError on a missing key, and .strip() raised
# AttributeError on null or a number. Neither is something the agent can act
# on — anything that is not a usable string is "no name given".
tool_name = requested.strip() if isinstance(requested, str) else ""
if not tool_name:
return self._index()
# A hand-written manual wins, under any spelling the model may use for it.
manual = _read_manual(tool_name)
if manual is not None:
return ToolResult(success=True, output=manual)
profile_id = self._active_profile_id(ctx)
scoped = self._profile_tool_map(profile_id)
_, tool = resolve_tool(scoped, tool_name)
if tool is not None:
return ToolResult(success=True, output=_auto_manual(tool))
# Registered, but not enabled for this profile. Documenting it is still the
# useful answer, and saying so stops the agent from calling it and getting
# "tool not found" back from the executor. A hint, never a refusal.
_, elsewhere = resolve_tool(self._registry_map(), tool_name)
if elsewhere is not None:
where = f"profile '{profile_id}'" if profile_id else "this run"
note = (
f"'{elsewhere.name}' is registered but not enabled for {where}. Calling it "
"would fail with 'tool not found' — pick an enabled tool, or ask the user "
"to enable it."
)
return ToolResult(success=True, output=f"[{note}]\n\n{_auto_manual(elsewhere)}")
# Not a tool — but possibly a whole MCP server, which is where a server's
# instructions live now that the system prompt only keeps one line each.
server_manual = self._server_manual(tool_name)
if server_manual is not None:
return ToolResult(success=True, output=server_manual)
return self._not_found(tool_name, scoped)
# ── helpers ──────────────────────────────────────────────────────────
@staticmethod
def _active_profile_id(ctx: ToolContext | None) -> str:
return ((ctx.profile_id if ctx else None) or current_profile_id.get() or "").strip()
def _registry_map(self) -> dict[str, Tool]:
"""Every registered tool, as {name: Tool} for resolve_tool()."""
if self._registry is None:
return {}
return {tool.name: tool for tool in self._registry.all()}
def _server_manual(self, name: str) -> str | None:
"""The manual for an MCP *server* — the server itself, not one of its tools.
The system prompt names each reachable server in one line; the instructions
that used to follow that line are read here instead. A server is documented
whether or not it is connected: its config is on disk, and an offline server
is exactly when its tools' guidance is worth re-reading.
"""
if self._mcp_manager is None:
return None
server = _match_server(self._mcp_manager.configured_servers(), name)
if server is None:
return None
instructions = self._mcp_manager.get_instructions({server}).get(server, "")
return _render_server_manual(
server, instructions, self._mcp_manager.server_tool_names(server)
)
def _profile_tool_map(self, profile_id: str) -> dict[str, Tool]:
"""The tools this profile can call, as {name: Tool} for resolve_tool().
Union of the agent and subagent scopes — a sub-agent asking about a tool that
only the parent's agent scope enables must not be told it is "not enabled": it
cannot tell which scope it runs in, and half the answers would be wrong.
Built with build_tool_list, the same builder the agent uses for its real
toolset, so this cannot drift from what the executor will resolve.
"""
if not profile_id or self._registry is None or self._profile_registry is None:
return {}
# Deferred: see execute().
from navi.core.tool_utils import build_tool_list
try:
profile = self._profile_registry.get(profile_id)
except Exception:
return {}
tools: list[Tool] = []
for scope_config in (profile.get_agent_tools(), profile.get_subagent_tools()):
tools.extend(
build_tool_list(
scope_config.native, scope_config.mcp, self._registry, self._mcp_manager
)
)
return {tool.name: tool for tool in tools}
def _index(self) -> ToolResult:
"""No name given — the catalogue of hand-written manuals, by tool source.
An agent that does not know which tools have real docs can otherwise only
guess names and collect 'not found' answers. This is the answer to "what is
documented?", so it succeeds: nothing was asked for, nothing is wrong.
"""
by_source: dict[str, list[str]] = {}
registry_map = self._registry_map()
for path in _manual_index().values():
if path.parent == GUIDES_DIR:
continue # a guide is not a tool
stem = path.stem
by_source.setdefault(_source_for_manual(stem, registry_map), []).append(stem)
if not by_source:
return ToolResult(
success=False,
output="No hand-written manuals are installed — pass tool_name to get "
"a manual generated from that tool's schema.",
error="no_manuals",
)
lines = ["Hand-written manuals, by tool source — call tool_manual(tool_name=…) for one:"]
for source in sorted(by_source, key=lambda s: (s != NATIVE_SOURCE, s)):
names = sorted(by_source[source])
lines.append(f"{source} ({len(names)}): {', '.join(names)}")
guides = sorted(p.stem for p in _guide_paths())
if guides:
lines.append(f"guides (not tools; readable by name): {', '.join(guides)}")
servers = self._documented_servers()
if servers:
lines.append(
f"MCP servers (call tool_manual with the server name for its full "
f"instructions and tool list): {', '.join(servers)}"
)
lines.append(
"Any other tool still works: tool_manual returns a manual generated from "
"its schema."
)
return ToolResult(success=True, output="\n".join(lines))
def _documented_servers(self) -> list[str]:
"""Configured MCP servers that have something to say about themselves."""
if self._mcp_manager is None:
return []
return sorted(self._mcp_manager.get_summaries().keys())
def _not_found(self, tool_name: str, scoped: dict[str, Tool]) -> ToolResult:
"""Nothing matched — suggest instead of leaving the agent to guess."""
known = {
name.lower(): name
for name in set(scoped) | set(self._registry_map()) | set(_manual_index())
}
# A bare MCP name ("web_search") is a legitimate spelling of
# "mcp__navi-web__web_search" — resolve_tool accepts it, so a typo of one has
# to be suggestible too, not filed under "nothing registered".
for full in list(known.values()):
segment = full.rsplit("__", 1)[-1]
if segment != full:
known.setdefault(segment.lower(), full)
suggestions = difflib.get_close_matches(
tool_name.lower(), list(known), n=_MAX_SUGGESTIONS, cutoff=0.6
)
lines = [f"No tool named '{tool_name}' — nothing is registered under that name."]
if suggestions:
lines.append(f"Did you mean: {', '.join(known[s] for s in suggestions)}?")
available = sorted(manual_names())
if available:
lines.append(f"Tools with a hand-written manual: {', '.join(available)}.")
lines.append("list_tools lists every tool this profile can call.")
return ToolResult(success=False, output="\n".join(lines), error="not_found")
def _manual_index() -> dict[str, Path]:
"""Lower-cased name -> manual file, over manuals/ and manuals/guides/.
Manuals are named after the tool they document. Guides are not tools, but they
are indexed too so a guide can still be read by name.
"""
index: dict[str, Path] = {}
for base in (MANUALS_DIR, GUIDES_DIR):
if not base.is_dir():
continue
for path in sorted(base.glob("*.md")):
index.setdefault(path.stem.lower(), path)
return index
def _manual_candidates(name: str) -> list[str]:
"""Spellings `name` may be filed under, best first, de-duplicated."""
out = [name]
# The agent calls MCP tools by their full name (mcp__navi-3d__compile_scad) while
# the file is named after the tool alone (compile_scad.md).
for separator in ("__", ":"):
if separator in name:
out.append(name.rsplit(separator, 1)[-1])
# Dash vs underscore is the other way the same tool gets spelled.
for candidate in list(out):
out += [candidate.replace("-", "_"), candidate.replace("_", "-")]
seen: set[str] = set()
unique: list[str] = []
for candidate in out:
key = candidate.lower()
if candidate and key not in seen:
seen.add(key)
unique.append(candidate)
return unique
def _read_manual(name: str) -> str | None:
"""The hand-written manual for `name`, or None when no file matches."""
index = _manual_index()
for candidate in _manual_candidates(name):
path = index.get(candidate.lower())
if path is None:
continue
try:
return path.read_text(encoding="utf-8")
except OSError:
return None
return None
def _guide_paths() -> list[Path]:
if not GUIDES_DIR.is_dir():
return []
return sorted(GUIDES_DIR.glob("*.md"))
def _match_server(servers: list[str], name: str) -> str | None:
"""Resolve a requested name against the configured server names.
Servers are spelled both ways in practice — the config file is `navi-3d.json`
while the tool prefix the agent sees is `mcp__navi-3d__…`, and a model retyping
it may use either dash or underscore. Same tolerance ``resolve_tool`` gives
tool names, for the same reason.
"""
lowered = name.strip().lower()
normalized = lowered.replace("-", "_")
for server in servers:
candidate = server.lower()
if candidate == lowered or candidate.replace("-", "_") == normalized:
return server
return None
def _render_server_manual(server: str, instructions: str, tools: list[str]) -> str:
"""The full text behind the one line the system prompt spends on a server."""
lines = [
f"# {server} — MCP server manual",
"",
f"An MCP server; its tools are enabled per profile and named "
f"`mcp__{server}__<tool>`.",
]
if tools:
lines.append(f"Tools it exposes ({len(tools)}): " + ", ".join(f"`{t}`" for t in tools))
lines.append("")
if instructions.strip():
lines.append(f"> Written for this machine in `mcp_servers.d/{server}.json`, merged")
lines.append("> with whatever the server announces when it connects.")
lines.append("")
lines.append(instructions.strip())
else:
lines.append(
"No instructions are configured for this server. Its tools' own descriptions "
"are all the guidance there is — `tool_manual(\"<tool>\")` returns any one of "
"them."
)
return "\n".join(lines)
def manual_names() -> set[str]:
"""Tool names with a hand-written manual (guides excluded — they are not tools)."""
if not MANUALS_DIR.is_dir():
return set()
return {path.stem for path in MANUALS_DIR.glob("*.md")}
def _source_of(name: str) -> str:
"""Where a tool comes from, spelled as the agent sees it: 'native' or 'mcp__<server>__'."""
if not name.startswith(_MCP_PREFIX):
return NATIVE_SOURCE
server = name[len(_MCP_PREFIX) :].split(_MCP_SEP, 1)[0]
return f"{_MCP_PREFIX}{server}{_MCP_SEP}"
def _source_for_manual(stem: str, registry_map: dict) -> str:
"""A manual is filed under the tool's own name, so ask the registry where that
tool actually lives — 'compile_scad.md' documents an MCP tool, not a built-in.
"""
from navi.core.tool_utils import resolve_tool
_, tool = resolve_tool(registry_map, stem)
return _source_of(tool.name if tool is not None else stem)
def _auto_manual(tool) -> str:
"""A manual generated from the tool's live JSON schema.
The header says which source it is and that this is a schema, not a curated
guide — otherwise the agent cannot tell a thin parameter contract from a real
manual and may read the absence of examples as the tool being simple.
"""
schema = tool.parameters or {}
lines = [
f"# {tool.name}",
"",
tool.description or "(this tool has no description)",
"",
f"> Generated from the tool's JSON schema (source: {_source_of(tool.name)}). "
"This is the parameter contract, not a hand-written manual.",
"",
"## Parameters",
]
props = schema.get("properties") or {}
if not props:
lines.append("")
lines.append("This tool takes no parameters.")
return "\n".join(lines)
lines.append("")
_render_properties(props, schema.get("required") or [], lines, "", 0)
return "\n".join(lines)
def _render_properties(props: dict, required, lines: list[str], indent: str, depth: int) -> None:
"""One bullet per parameter, with its nested structure indented underneath."""
required = set(required)
for param_name, spec in props.items():
if not isinstance(spec, dict):
spec = {}
lines.append(f"{indent}- `{param_name}`{_describe(spec, param_name in required)}")
_render_nested(spec, lines, indent + " ", depth)
def _render_nested(spec: dict, lines: list[str], indent: str, depth: int) -> None:
"""Whatever hangs under a parameter: object fields, array items, union branches.
A model that cannot see the shape of an array of objects guesses it and gets the
call wrong, so object fields and array items are always spelled out.
"""
if depth >= _MAX_DEPTH:
lines.append(f"{indent}… (nested deeper; see the tool's schema)")
return
nested = spec.get("properties")
if nested:
_render_properties(nested, spec.get("required") or [], lines, indent, depth + 1)
return
items = spec.get("items")
if isinstance(items, dict) and (items.get("properties") or items.get("oneOf") or items.get("anyOf")):
lines.append(f"{indent}each item:")
_render_nested(items, lines, indent + " ", depth + 1)
for union in ("oneOf", "anyOf"):
branches = [b for b in (spec.get(union) or []) if isinstance(b, dict)]
if not branches:
continue
lines.append(f"{indent}{union} — one of:")
for branch in branches:
lines.append(f"{indent} • {_branch_summary(branch)}")
_render_nested(branch, lines, indent + " ", depth + 1)
def _describe(spec: dict, required: bool) -> str:
"""`(string, required): description — one of: …; default: …`"""
label = ", ".join([_type_of(spec), "required" if required else "optional"])
notes = []
enum = spec.get("enum")
if enum:
notes.append("one of: " + ", ".join(repr(v) for v in enum))
if "const" in spec:
notes.append(f"const: {spec['const']!r}")
if "default" in spec:
notes.append(f"default: {spec['default']!r}")
note = (" — " + "; ".join(notes)) if notes else ""
return f" ({label}): {spec.get('description') or ''}{note}"
def _type_of(spec: dict) -> str:
"""The type label, inferred when the schema expresses it as a union or array."""
declared = spec.get("type")
if isinstance(declared, list):
return " | ".join(str(t) for t in declared)
if declared == "array":
items = spec.get("items")
return f"array of {_type_of(items)}" if isinstance(items, dict) else "array"
if declared:
return str(declared)
alternatives = [b for b in (spec.get("oneOf") or []) + (spec.get("anyOf") or []) if isinstance(b, dict)]
if alternatives:
return " | ".join(_type_of(branch) for branch in alternatives)
if spec.get("properties"):
return "object"
return "any"
def _branch_summary(branch: dict) -> str:
"""One line for a oneOf/anyOf alternative: its type plus whatever pins it down."""
label = _type_of(branch)
if "const" in branch:
return f"{label} = {branch['const']!r}"
enum = branch.get("enum")
if enum:
return f"{label}: {', '.join(repr(v) for v in enum)}"
if branch.get("description"):
return f"{label} — {branch['description']}"
return label