nanobot/nanobot/agent/tool_routing.py
tanyar09 7050e032e8
Some checks failed
CI / Lint with ruff (pull_request) Failing after 47s
CI / Test Python 3.11 (pull_request) Successful in 51s
CI / Test Python 3.12 (pull_request) Successful in 50s
CI / Build package (pull_request) Has been cancelled
Improve MCP tool calling and routing
Add explicit JSON tool-call protocol for local providers, improve parsing of JSON-only tool calls, and add heuristic routing to MCP-capable profiles for repo/PR intents. Also document and mount local-cloned MCP servers and expand MCP env var handling.

Made-with: Cursor
2026-03-31 12:15:05 -04:00

119 lines
3.9 KiB
Python

"""LLM-based router: choose a tools.toolProfiles key from the user message."""
from __future__ import annotations
import json_repair
from loguru import logger
from nanobot.config.schema import ToolProfileConfig
from nanobot.providers.base import LLMProvider
async def route_tool_profile(
provider: LLMProvider,
*,
model: str,
user_message: str,
profiles: dict[str, ToolProfileConfig],
default_profile: str,
temperature: float = 0.2,
max_tokens: int = 128,
) -> str:
"""
Ask a small LLM call to return JSON {"profile": "<key>"}.
Falls back to default_profile on any failure or unknown key.
"""
if not profiles:
return default_profile
# Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos),
# prefer an MCP-enabled profile without spending an LLM call.
msg_l = (user_message or "").lower()
needs_forge = any(
k in msg_l
for k in [
"pull request",
"pull requests",
"open pr",
"open prs",
" list prs",
"pr ",
"prs",
"merge request",
"issue",
"issues",
"gitea",
"repo",
"repository",
"branches",
"commits",
"tags",
"release",
]
)
if needs_forge:
# Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers.
for key in profiles.keys():
if "mcp" in key.lower():
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
for key, p in profiles.items():
if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0):
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
lines = []
for name, p in profiles.items():
desc = (p.description or "").strip() or "(no description)"
lines.append(f"- {name}: {desc}")
catalog = "\n".join(lines)
allowed = ", ".join(f'"{k}"' for k in profiles)
system = (
"You are a tool-profile router. Pick exactly one profile key for the assistant's next turn. "
"Respond with JSON only: {\"profile\": \"<key>\"} where <key> is one of: "
f"{allowed}. "
"Prefer narrower profiles when the request is clearly scoped (e.g. only read files). "
"Use the broadest profile only when multiple unrelated capabilities are needed."
)
user = f"Available profiles:\n{catalog}\n\nUser message:\n{user_message.strip()[:8000]}"
try:
response = await provider.chat(
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
tools=None,
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
text = (response.content or "").strip()
if not text:
return default_profile
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
data = json_repair.loads(text)
if not isinstance(data, dict):
return default_profile
name = data.get("profile")
if isinstance(name, str) and name in profiles:
logger.info(f"Tool router selected profile '{name}'")
return name
logger.warning(f"Tool router returned invalid profile {name!r}, using default")
except (TypeError, ValueError) as e:
logger.warning(f"Tool router JSON parse failed: {e}")
except Exception as e:
logger.warning(f"Tool router failed: {e}")
return default_profile
def is_tool_not_found_error(result: str) -> bool:
"""Detect registry execute() message for missing tools."""
if not result:
return False
return result.startswith("Error: Tool '") and "' not found" in result