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
119 lines
3.9 KiB
Python
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
|