Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/atlasomnia_hermes-tool-router.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
85 KiB
JSON

{"repo": "atlasomnia/hermes-tool-router", "files": {"policy.py": {"1cbe6b6c4cd4179d29ad152a788f14990f359bb9": {"t": 1785745442.381909, "n": 16713, "h": "b36cde29c9303899", "c": "\"\"\"Toolset prediction policy for hermes-token-router.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom typing import Any, Dict, List, Optional, Set\n\ntry:\n from .config import PLUGIN_NAME\n from .classifier import call_with_hard_timeout\n from .intent import INTENT_TOOLSETS, Intent, classify_intent\nexcept ImportError: # pragma: no cover - direct loader fallback\n from config import PLUGIN_NAME\n from classifier import call_with_hard_timeout\n from intent import INTENT_TOOLSETS, Intent, classify_intent\n\nlogger = logging.getLogger(__name__)\n\nTOOLSET_DESCRIPTIONS: Dict[str, str] = {\n \"terminal\": \"Run shell commands, install packages, manage processes\",\n \"web\": \"Search and extract content from web pages\",\n \"file\": \"Read, write, search, and manage files\",\n \"memory\": \"Store and retrieve long-term memories, manage recall\",\n \"skills\": \"Create, edit, view, and manage Hermes skills\",\n \"browser\": \"Navigate, click, fill forms, interact with web pages\",\n \"image_gen\": \"Generate images via DALL-E or Stable Diffusion\",\n \"video_gen\": \"Generate videos\",\n \"video\": \"Analyze or generate video content\",\n \"vision\": \"Analyze images — screenshots, photos, diagrams\",\n \"git\": \"Git operations — commit, push, pull, branch, clone\",\n \"code_execution\": \"Run sandboxed code (Python, Node, shell) safely\",\n \"kanban\": \"Kanban board — create, assign, move tasks\",\n \"context_engine\": \"Context-aware processing with custom rules\",\n \"mcp\": \"MCP server tool discovery and interaction\",\n \"approval\": \"Approve dangerous shell commands mid-execution\",\n \"tts\": \"Text-to-speech audio generation\",\n \"acp\": \"ACP filesystem and edit operations\",\n \"discord\": \"Discord bot — send messages, manage channels\",\n \"slack\": \"Slack integration — messages, reactions, threads\",\n \"telegram\": \"Telegram bot integration\",\n \"delegation\": \"Delegate tasks to subagents\",\n \"messaging\": \"Send messages through configured gateway platforms\",\n}\nTOOLSET_INTENT_RULES: List[tuple[str, Set[str]]] = [\n (r\"\\b(reddit|subreddit|r/[A-Za-z0-9_]+)\\b\", {\"web\", \"browser\"}),\n (r\"\\b(open|click|browse|browser|web\\s?page|screenshot|log\\s?in|form|navigate)\\b|https?://\", {\"browser\", \"web\"}),\n (r\"\\b(search|look\\s?up|google|web|online|current|latest|today|news|weather|price|schedule)\\b\", {\"web\"}),\n (r\"\\b(file|folder|path|read|write|edit|save|delete|rename|copy|move|upload|download)\\b\", {\"file\"}),\n (r\"\\b(run|command|terminal|shell|install|build|test|pytest|npm|python|powershell|process)\\b\", {\"terminal\"}),\n (r\"\\b(git|commit|branch|checkout|diff|merge|push|pull|repo|repository|pr|pull request)\\b\", {\"git\", \"file\", \"terminal\"}),\n (r\"\\b(code|debug|fix|implement|refactor|compile|lint)\\b\", {\"file\", \"terminal\"}),\n (r\"\\b(image|picture|photo|diagram|screenshot|vision|analy[sz]e this)\\b\", {\"vision\"}),\n (r\"\\b(generate|create|draw|make).{0,40}\\b(image|picture|photo|art)\\b\", {\"image_gen\"}),\n (r\"\\b(video|movie|clip|animation)\\b\", {\"video_gen\", \"video\"}),\n (r\"\\b(remember|memory|recall|save this|store this)\\b\", {\"memory\"}),\n (r\"\\b(skill|skills)\\b\", {\"skills\"}),\n (r\"\\b(delegate|subagent|sub-agent|multi-agent|worker agent)\\b\", {\"delegation\"}),\n (r\"\\b(discord)\\b\", {\"discord\", \"messaging\"}),\n (r\"\\b(slack)\\b\", {\"slack\", \"messaging\"}),\n (r\"\\b(telegram)\\b\", {\"telegram\", \"messaging\"}),\n (r\"\\b(speak|say aloud|tts|voice)\\b\", {\"tts\"}),\n]\nACTION_HINT_RE = re.compile(\n r\"\\b(\"\n r\"access|analy[sz]e|browse|build|call|change|check|click|commit|create|debug|\"\n r\"download|edit|find|fix|generate|install|look\\s?up|make|modify|open|read|\"\n r\"remember|repair|research|run|save|screenshot|search|send|test|update|write\"\n r\")\\b|https?://\",\n re.IGNORECASE,\n)\nPLAIN_ANSWER_RE = re.compile(\n r\"\\b(\"\n r\"what is|what are|who is|who are|why|how does|how do|explain|define|\"\n r\"compare|difference between|summari[sz]e concept|tell me about\"\n r\")\\b\",\n re.IGNORECASE,\n)\nROUTER_SYSTEM_PROMPT = \"\"\"You are a tool-router classifier. Given a user message and a list of available toolsets with descriptions, predict which toolsets the AI assistant will need to respond.\n\nRules:\n1. Return ONLY JSON: {{\"toolsets\": [\"name\"], \"confidence\": 0.0}}.\n2. confidence must be a number from 0.0 to 1.0.\n3. Include ONLY toolsets directly relevant to the user's request.\n4. Do not include terminal, file, or web unless the request actually needs them.\n5. For ordinary questions that can be answered without tools, return an empty array.\n6. If the message is ambiguous or uncertain, return \"all\" as the only toolset.\n7. Be conservative; only include toolsets you are confident are needed.\n8. Personal calendar, itinerary, reservation, or flight-detail lookups require \"skills\" and \"terminal\" when those toolsets are available.\nExample response:\n{{\"toolsets\": [\"terminal\", \"file\"], \"confidence\": 0.96}}\n\nAvailable toolsets:\n{toolset_descriptions}\n\nUser message: {user_message}\n\"\"\"\ndef _get_router_client(\n router_model: str,\n router_provider: str = \"deepseek\",\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n):\n \"\"\"Obtain an OpenAI-compatible client for the router.\n\n Supports:\n - deepseek: uses DEEPSEEK_API_KEY, standard Chat Completions API\n - openrouter: uses OPENROUTER_API_KEY, standard Chat Completions API\n - openai-codex: uses OAuth credential pool (Responses API — not yet implemented)\n\n Returns (client, model_name) or (None, None) on failure.\n \"\"\"\n try:\n from openai import OpenAI\n\n if router_provider == \"deepseek\":\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no DEEPSEEK_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://api.deepseek.com\",\n timeout=8,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openrouter\":\n api_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no OPENROUTER_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://openrouter.ai/api/v1\",\n timeout=8,\n max_retries=0,\n default_headers={\n \"HTTP-Referer\": \"https://github.com/hermes-agent/hermes-token-router\",\n \"X-Title\": \"Hermes Tool Router\",\n },\n )\n return client, router_model\n\n elif router_provider == \"custom\":\n if not custom_base_url:\n logger.debug(\"%s: custom classifier requires base_url\", PLUGIN_NAME)\n return None, None\n api_key = os.environ.get(custom_api_key_env, \"\") if custom_api_key_env else \"local\"\n if custom_api_key_env and not api_key:\n logger.debug(\n \"%s: custom classifier key env %s is unset\",\n PLUGIN_NAME,\n custom_api_key_env,\n )\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=custom_base_url,\n timeout=2,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openai-codex\":\n logger.warning(\n \"%s: router_provider=openai-codex is disabled here because the \"\n \"Codex backend requires the Responses API; using full toolset fallback\",\n PLUGIN_NAME,\n )\n return None, None\n\n else:\n logger.warning(\n \"%s: unknown router_provider '%s'\", PLUGIN_NAME, router_provider\n )\n return None, None\n\n except Exception as exc:\n logger.warning(\n \"%s: failed to initialize router client: %s\",\n PLUGIN_NAME,\n exc,\n )\n return None, None\ndef _get_available_toolsets() -> Set[str]:\n \"\"\"Return all known toolset names from the registry.\"\"\"\n try:\n from tools.registry import registry\n return set(registry.get_registered_toolset_names())\n except Exception:\n return set()\ndef _build_toolset_description_block(available: Set[str]) -> str:\n \"\"\"Build a formatted string of toolset descriptions for the router prompt.\"\"\"\n lines = []\n for name in sorted(available):\n desc = TOOLSET_DESCRIPTIONS.get(name, f\"Tools for {name}\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\ndef _predict_toolsets_by_rules(\n user_message: str,\n available_toolsets: Set[str],\n) -> tuple[Optional[Set[str]], str]:\n \"\"\"Fast deterministic routing for obvious intents.\n\n Returns (toolsets, reason). toolsets=None means the LLM router should decide.\n An empty set is a valid prediction for plain-answer turns.\n \"\"\"\n text = (user_message or \"\").strip()\n lowered = text.lower()\n if not lowered:\n return None, \"empty\"\n\n intent_result = classify_intent(text)\n if intent_result.intents == frozenset({Intent.ANSWER_ONLY}):\n return set(), f\"intent:{intent_result.reason_code}\"\n if Intent.FULL_SURFACE not in intent_result.intents:\n intent_toolsets: Set[str] = set()\n for intent in intent_result.intents:\n intent_toolsets.update(INTENT_TOOLSETS.get(intent, frozenset()))\n intent_toolsets &= available_toolsets\n if intent_toolsets:\n return intent_toolsets, f\"intent:{intent_result.reason_code}\"\n\n matched: Set[str] = set()\n matched_rules = 0\n for pattern, toolsets in TOOLSET_INTENT_RULES:\n if re.search(pattern, lowered, re.IGNORECASE):\n matched.update(toolsets)\n matched_rules += 1\n\n matched &= available_toolsets\n if matched:\n return matched, f\"rules:{matched_rules}\"\n\n words = re.findall(r\"[a-z0-9']+\", lowered)\n if len(words) <= 4 and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_short\"\n\n if PLAIN_ANSWER_RE.search(lowered) and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_answer\"\n\n if not ACTION_HINT_RE.search(lowered) and len(text) <= 240:\n return set(), \"plain_default\"\n\n return None, \"needs_llm\"\ndef _extract_confidence(result: Any) -> Optional[float]:\n \"\"\"Extract confidence from router output; missing confidence is unknown.\"\"\"\n if not isinstance(result, dict):\n return None\n\n for key in (\"confidence\", \"probability\", \"prob\", \"score\", \"confidence_score\"):\n raw = result.get(key)\n if raw is None or isinstance(raw, bool):\n continue\n\n parsed: Optional[float]\n if isinstance(raw, (int, float)):\n parsed = float(raw)\n elif isinstance(raw, str):\n try:\n parsed = float(raw.strip().replace(\"%\", \"\"))\n except ValueError:\n parsed = None\n else:\n parsed = None\n\n if parsed is None or parsed < 0:\n continue\n\n if parsed > 1 and parsed <= 100:\n parsed = parsed / 100.0\n\n return parsed\n\n return None\ndef _predict_toolsets_via_llm(\n user_message: str,\n available_toolsets: Set[str],\n router_model: str,\n confidence_threshold: float,\n router_provider: str = \"deepseek\",\n hard_timeout_seconds: float = 1.2,\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n) -> Optional[Set[str]]:\n \"\"\"Use the router model to predict which toolsets are needed.\n\n Returns a reduced set of toolsets, or None to use full set (bypass).\n On any error, returns None for safe fallback.\n \"\"\"\n try:\n client, model = _get_router_client(\n router_model,\n router_provider,\n custom_base_url,\n custom_api_key_env,\n )\n if client is None:\n logger.debug(\n \"%s: router client unavailable — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n\n toolset_desc = _build_toolset_description_block(available_toolsets)\n \n prompt = ROUTER_SYSTEM_PROMPT.format(\n toolset_descriptions=toolset_desc,\n user_message=user_message[:1500], # Limit message length for router\n )\n\n start = time.monotonic()\n logger.debug(\n \"%s: calling chat.completions.create provider=%s model=%s\",\n PLUGIN_NAME,\n router_provider,\n model,\n )\n\n def invoke_classifier():\n return client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"system\", \"content\": prompt}],\n temperature=0.0,\n max_tokens=200,\n )\n\n try:\n completed, response = call_with_hard_timeout(invoke_classifier, hard_timeout_seconds)\n if not completed or response is None:\n logger.warning(\n \"%s: router prediction timed out after %.2fs — full set fallback\",\n PLUGIN_NAME,\n hard_timeout_seconds,\n )\n return None\n except Exception as thread_exc:\n logger.warning(\n \"%s: router API call failed: %s — full set fallback\",\n PLUGIN_NAME, thread_exc,\n )\n return None\n elapsed = time.monotonic() - start\n\n content = response.choices[0].message.content or \"\"\n \n logger.debug(\n \"%s: router model=%s responded in %.2fs: %.100s\",\n PLUGIN_NAME,\n model,\n elapsed,\n content.strip(),\n )\n\n # Parse JSON response\n content = content.strip()\n # Remove markdown code fences if present\n if content.startswith(\"```\"):\n content = content.split(\"\\n\", 1)[-1]\n content = content.rsplit(\"```\", 1)[0]\n content = content.strip()\n\n result = json.loads(content)\n predicted_list = result.get(\"toolsets\", [])\n\n confidence = _extract_confidence(result)\n if confidence is None:\n logger.info(\"%s: router omitted confidence; keeping full toolset\", PLUGIN_NAME)\n return None\n if confidence < confidence_threshold:\n logger.info(\n \"%s: router confidence %.3f below threshold %.3f; keeping full toolset\",\n PLUGIN_NAME,\n confidence,\n confidence_threshold,\n )\n return None\n\n if not isinstance(predicted_list, list):\n logger.debug(\n \"%s: router returned non-list: %s\",\n PLUGIN_NAME, result,\n )\n return None\n\n # Handle \"all\" bypass signal\n if \"all\" in predicted_list:\n logger.debug(\n \"%s: router signaled 'all' — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # Filter to only known toolsets. An empty list is a valid no-tool prediction.\n predicted = set(predicted_list) & available_toolsets\n\n if not predicted and predicted_list:\n logger.debug(\n \"%s: router returned empty set after filtering — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # If predicted is the same as full set, no reduction needed\n if predicted == available_toolsets:\n logger.debug(\n \"%s: router predicted all available toolsets — no reduction\",\n PLUGIN_NAME,\n )\n return None\n\n logger.info(\n \"%s: router model=%s predicted %d toolsets (from %d) in %.2fs: %s\",\n PLUGIN_NAME,\n model,\n len(predicted),\n len(available_toolsets),\n elapsed,\n sorted(predicted),\n )\n\n return predicted\n\n except json.JSONDecodeError as exc:\n logger.warning(\n \"%s: failed to parse router response: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n except Exception as exc:\n logger.warning(\n \"%s: router prediction failed: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n"}, "ed03a726ed514b2eb8a62705f3ecd7ce2f7818c5": {"t": 1785753264.210252, "n": 16713, "h": "b36cde29c9303899", "c": "\"\"\"Toolset prediction policy for hermes-token-router.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom typing import Any, Dict, List, Optional, Set\n\ntry:\n from .config import PLUGIN_NAME\n from .classifier import call_with_hard_timeout\n from .intent import INTENT_TOOLSETS, Intent, classify_intent\nexcept ImportError: # pragma: no cover - direct loader fallback\n from config import PLUGIN_NAME\n from classifier import call_with_hard_timeout\n from intent import INTENT_TOOLSETS, Intent, classify_intent\n\nlogger = logging.getLogger(__name__)\n\nTOOLSET_DESCRIPTIONS: Dict[str, str] = {\n \"terminal\": \"Run shell commands, install packages, manage processes\",\n \"web\": \"Search and extract content from web pages\",\n \"file\": \"Read, write, search, and manage files\",\n \"memory\": \"Store and retrieve long-term memories, manage recall\",\n \"skills\": \"Create, edit, view, and manage Hermes skills\",\n \"browser\": \"Navigate, click, fill forms, interact with web pages\",\n \"image_gen\": \"Generate images via DALL-E or Stable Diffusion\",\n \"video_gen\": \"Generate videos\",\n \"video\": \"Analyze or generate video content\",\n \"vision\": \"Analyze images — screenshots, photos, diagrams\",\n \"git\": \"Git operations — commit, push, pull, branch, clone\",\n \"code_execution\": \"Run sandboxed code (Python, Node, shell) safely\",\n \"kanban\": \"Kanban board — create, assign, move tasks\",\n \"context_engine\": \"Context-aware processing with custom rules\",\n \"mcp\": \"MCP server tool discovery and interaction\",\n \"approval\": \"Approve dangerous shell commands mid-execution\",\n \"tts\": \"Text-to-speech audio generation\",\n \"acp\": \"ACP filesystem and edit operations\",\n \"discord\": \"Discord bot — send messages, manage channels\",\n \"slack\": \"Slack integration — messages, reactions, threads\",\n \"telegram\": \"Telegram bot integration\",\n \"delegation\": \"Delegate tasks to subagents\",\n \"messaging\": \"Send messages through configured gateway platforms\",\n}\nTOOLSET_INTENT_RULES: List[tuple[str, Set[str]]] = [\n (r\"\\b(reddit|subreddit|r/[A-Za-z0-9_]+)\\b\", {\"web\", \"browser\"}),\n (r\"\\b(open|click|browse|browser|web\\s?page|screenshot|log\\s?in|form|navigate)\\b|https?://\", {\"browser\", \"web\"}),\n (r\"\\b(search|look\\s?up|google|web|online|current|latest|today|news|weather|price|schedule)\\b\", {\"web\"}),\n (r\"\\b(file|folder|path|read|write|edit|save|delete|rename|copy|move|upload|download)\\b\", {\"file\"}),\n (r\"\\b(run|command|terminal|shell|install|build|test|pytest|npm|python|powershell|process)\\b\", {\"terminal\"}),\n (r\"\\b(git|commit|branch|checkout|diff|merge|push|pull|repo|repository|pr|pull request)\\b\", {\"git\", \"file\", \"terminal\"}),\n (r\"\\b(code|debug|fix|implement|refactor|compile|lint)\\b\", {\"file\", \"terminal\"}),\n (r\"\\b(image|picture|photo|diagram|screenshot|vision|analy[sz]e this)\\b\", {\"vision\"}),\n (r\"\\b(generate|create|draw|make).{0,40}\\b(image|picture|photo|art)\\b\", {\"image_gen\"}),\n (r\"\\b(video|movie|clip|animation)\\b\", {\"video_gen\", \"video\"}),\n (r\"\\b(remember|memory|recall|save this|store this)\\b\", {\"memory\"}),\n (r\"\\b(skill|skills)\\b\", {\"skills\"}),\n (r\"\\b(delegate|subagent|sub-agent|multi-agent|worker agent)\\b\", {\"delegation\"}),\n (r\"\\b(discord)\\b\", {\"discord\", \"messaging\"}),\n (r\"\\b(slack)\\b\", {\"slack\", \"messaging\"}),\n (r\"\\b(telegram)\\b\", {\"telegram\", \"messaging\"}),\n (r\"\\b(speak|say aloud|tts|voice)\\b\", {\"tts\"}),\n]\nACTION_HINT_RE = re.compile(\n r\"\\b(\"\n r\"access|analy[sz]e|browse|build|call|change|check|click|commit|create|debug|\"\n r\"download|edit|find|fix|generate|install|look\\s?up|make|modify|open|read|\"\n r\"remember|repair|research|run|save|screenshot|search|send|test|update|write\"\n r\")\\b|https?://\",\n re.IGNORECASE,\n)\nPLAIN_ANSWER_RE = re.compile(\n r\"\\b(\"\n r\"what is|what are|who is|who are|why|how does|how do|explain|define|\"\n r\"compare|difference between|summari[sz]e concept|tell me about\"\n r\")\\b\",\n re.IGNORECASE,\n)\nROUTER_SYSTEM_PROMPT = \"\"\"You are a tool-router classifier. Given a user message and a list of available toolsets with descriptions, predict which toolsets the AI assistant will need to respond.\n\nRules:\n1. Return ONLY JSON: {{\"toolsets\": [\"name\"], \"confidence\": 0.0}}.\n2. confidence must be a number from 0.0 to 1.0.\n3. Include ONLY toolsets directly relevant to the user's request.\n4. Do not include terminal, file, or web unless the request actually needs them.\n5. For ordinary questions that can be answered without tools, return an empty array.\n6. If the message is ambiguous or uncertain, return \"all\" as the only toolset.\n7. Be conservative; only include toolsets you are confident are needed.\n8. Personal calendar, itinerary, reservation, or flight-detail lookups require \"skills\" and \"terminal\" when those toolsets are available.\nExample response:\n{{\"toolsets\": [\"terminal\", \"file\"], \"confidence\": 0.96}}\n\nAvailable toolsets:\n{toolset_descriptions}\n\nUser message: {user_message}\n\"\"\"\ndef _get_router_client(\n router_model: str,\n router_provider: str = \"deepseek\",\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n):\n \"\"\"Obtain an OpenAI-compatible client for the router.\n\n Supports:\n - deepseek: uses DEEPSEEK_API_KEY, standard Chat Completions API\n - openrouter: uses OPENROUTER_API_KEY, standard Chat Completions API\n - openai-codex: uses OAuth credential pool (Responses API — not yet implemented)\n\n Returns (client, model_name) or (None, None) on failure.\n \"\"\"\n try:\n from openai import OpenAI\n\n if router_provider == \"deepseek\":\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no DEEPSEEK_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://api.deepseek.com\",\n timeout=8,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openrouter\":\n api_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no OPENROUTER_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://openrouter.ai/api/v1\",\n timeout=8,\n max_retries=0,\n default_headers={\n \"HTTP-Referer\": \"https://github.com/hermes-agent/hermes-token-router\",\n \"X-Title\": \"Hermes Tool Router\",\n },\n )\n return client, router_model\n\n elif router_provider == \"custom\":\n if not custom_base_url:\n logger.debug(\"%s: custom classifier requires base_url\", PLUGIN_NAME)\n return None, None\n api_key = os.environ.get(custom_api_key_env, \"\") if custom_api_key_env else \"local\"\n if custom_api_key_env and not api_key:\n logger.debug(\n \"%s: custom classifier key env %s is unset\",\n PLUGIN_NAME,\n custom_api_key_env,\n )\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=custom_base_url,\n timeout=2,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openai-codex\":\n logger.warning(\n \"%s: router_provider=openai-codex is disabled here because the \"\n \"Codex backend requires the Responses API; using full toolset fallback\",\n PLUGIN_NAME,\n )\n return None, None\n\n else:\n logger.warning(\n \"%s: unknown router_provider '%s'\", PLUGIN_NAME, router_provider\n )\n return None, None\n\n except Exception as exc:\n logger.warning(\n \"%s: failed to initialize router client: %s\",\n PLUGIN_NAME,\n exc,\n )\n return None, None\ndef _get_available_toolsets() -> Set[str]:\n \"\"\"Return all known toolset names from the registry.\"\"\"\n try:\n from tools.registry import registry\n return set(registry.get_registered_toolset_names())\n except Exception:\n return set()\ndef _build_toolset_description_block(available: Set[str]) -> str:\n \"\"\"Build a formatted string of toolset descriptions for the router prompt.\"\"\"\n lines = []\n for name in sorted(available):\n desc = TOOLSET_DESCRIPTIONS.get(name, f\"Tools for {name}\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\ndef _predict_toolsets_by_rules(\n user_message: str,\n available_toolsets: Set[str],\n) -> tuple[Optional[Set[str]], str]:\n \"\"\"Fast deterministic routing for obvious intents.\n\n Returns (toolsets, reason). toolsets=None means the LLM router should decide.\n An empty set is a valid prediction for plain-answer turns.\n \"\"\"\n text = (user_message or \"\").strip()\n lowered = text.lower()\n if not lowered:\n return None, \"empty\"\n\n intent_result = classify_intent(text)\n if intent_result.intents == frozenset({Intent.ANSWER_ONLY}):\n return set(), f\"intent:{intent_result.reason_code}\"\n if Intent.FULL_SURFACE not in intent_result.intents:\n intent_toolsets: Set[str] = set()\n for intent in intent_result.intents:\n intent_toolsets.update(INTENT_TOOLSETS.get(intent, frozenset()))\n intent_toolsets &= available_toolsets\n if intent_toolsets:\n return intent_toolsets, f\"intent:{intent_result.reason_code}\"\n\n matched: Set[str] = set()\n matched_rules = 0\n for pattern, toolsets in TOOLSET_INTENT_RULES:\n if re.search(pattern, lowered, re.IGNORECASE):\n matched.update(toolsets)\n matched_rules += 1\n\n matched &= available_toolsets\n if matched:\n return matched, f\"rules:{matched_rules}\"\n\n words = re.findall(r\"[a-z0-9']+\", lowered)\n if len(words) <= 4 and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_short\"\n\n if PLAIN_ANSWER_RE.search(lowered) and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_answer\"\n\n if not ACTION_HINT_RE.search(lowered) and len(text) <= 240:\n return set(), \"plain_default\"\n\n return None, \"needs_llm\"\ndef _extract_confidence(result: Any) -> Optional[float]:\n \"\"\"Extract confidence from router output; missing confidence is unknown.\"\"\"\n if not isinstance(result, dict):\n return None\n\n for key in (\"confidence\", \"probability\", \"prob\", \"score\", \"confidence_score\"):\n raw = result.get(key)\n if raw is None or isinstance(raw, bool):\n continue\n\n parsed: Optional[float]\n if isinstance(raw, (int, float)):\n parsed = float(raw)\n elif isinstance(raw, str):\n try:\n parsed = float(raw.strip().replace(\"%\", \"\"))\n except ValueError:\n parsed = None\n else:\n parsed = None\n\n if parsed is None or parsed < 0:\n continue\n\n if parsed > 1 and parsed <= 100:\n parsed = parsed / 100.0\n\n return parsed\n\n return None\ndef _predict_toolsets_via_llm(\n user_message: str,\n available_toolsets: Set[str],\n router_model: str,\n confidence_threshold: float,\n router_provider: str = \"deepseek\",\n hard_timeout_seconds: float = 1.2,\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n) -> Optional[Set[str]]:\n \"\"\"Use the router model to predict which toolsets are needed.\n\n Returns a reduced set of toolsets, or None to use full set (bypass).\n On any error, returns None for safe fallback.\n \"\"\"\n try:\n client, model = _get_router_client(\n router_model,\n router_provider,\n custom_base_url,\n custom_api_key_env,\n )\n if client is None:\n logger.debug(\n \"%s: router client unavailable — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n\n toolset_desc = _build_toolset_description_block(available_toolsets)\n \n prompt = ROUTER_SYSTEM_PROMPT.format(\n toolset_descriptions=toolset_desc,\n user_message=user_message[:1500], # Limit message length for router\n )\n\n start = time.monotonic()\n logger.debug(\n \"%s: calling chat.completions.create provider=%s model=%s\",\n PLUGIN_NAME,\n router_provider,\n model,\n )\n\n def invoke_classifier():\n return client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"system\", \"content\": prompt}],\n temperature=0.0,\n max_tokens=200,\n )\n\n try:\n completed, response = call_with_hard_timeout(invoke_classifier, hard_timeout_seconds)\n if not completed or response is None:\n logger.warning(\n \"%s: router prediction timed out after %.2fs — full set fallback\",\n PLUGIN_NAME,\n hard_timeout_seconds,\n )\n return None\n except Exception as thread_exc:\n logger.warning(\n \"%s: router API call failed: %s — full set fallback\",\n PLUGIN_NAME, thread_exc,\n )\n return None\n elapsed = time.monotonic() - start\n\n content = response.choices[0].message.content or \"\"\n \n logger.debug(\n \"%s: router model=%s responded in %.2fs: %.100s\",\n PLUGIN_NAME,\n model,\n elapsed,\n content.strip(),\n )\n\n # Parse JSON response\n content = content.strip()\n # Remove markdown code fences if present\n if content.startswith(\"```\"):\n content = content.split(\"\\n\", 1)[-1]\n content = content.rsplit(\"```\", 1)[0]\n content = content.strip()\n\n result = json.loads(content)\n predicted_list = result.get(\"toolsets\", [])\n\n confidence = _extract_confidence(result)\n if confidence is None:\n logger.info(\"%s: router omitted confidence; keeping full toolset\", PLUGIN_NAME)\n return None\n if confidence < confidence_threshold:\n logger.info(\n \"%s: router confidence %.3f below threshold %.3f; keeping full toolset\",\n PLUGIN_NAME,\n confidence,\n confidence_threshold,\n )\n return None\n\n if not isinstance(predicted_list, list):\n logger.debug(\n \"%s: router returned non-list: %s\",\n PLUGIN_NAME, result,\n )\n return None\n\n # Handle \"all\" bypass signal\n if \"all\" in predicted_list:\n logger.debug(\n \"%s: router signaled 'all' — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # Filter to only known toolsets. An empty list is a valid no-tool prediction.\n predicted = set(predicted_list) & available_toolsets\n\n if not predicted and predicted_list:\n logger.debug(\n \"%s: router returned empty set after filtering — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # If predicted is the same as full set, no reduction needed\n if predicted == available_toolsets:\n logger.debug(\n \"%s: router predicted all available toolsets — no reduction\",\n PLUGIN_NAME,\n )\n return None\n\n logger.info(\n \"%s: router model=%s predicted %d toolsets (from %d) in %.2fs: %s\",\n PLUGIN_NAME,\n model,\n len(predicted),\n len(available_toolsets),\n elapsed,\n sorted(predicted),\n )\n\n return predicted\n\n except json.JSONDecodeError as exc:\n logger.warning(\n \"%s: failed to parse router response: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n except Exception as exc:\n logger.warning(\n \"%s: router prediction failed: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n"}, "41f807a45f8251a2082062c9a16d70e77ef590f3": {"t": 1785753265.426176, "n": 16573, "h": "2f47275404a49abe", "c": "\"\"\"Toolset prediction policy for hermes-token-router.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom typing import Any, Dict, List, Optional, Set\n\ntry:\n from .config import PLUGIN_NAME\n from .classifier import call_with_hard_timeout\n from .intent import INTENT_TOOLSETS, Intent, classify_intent\nexcept ImportError: # pragma: no cover - direct loader fallback\n from config import PLUGIN_NAME\n from classifier import call_with_hard_timeout\n from intent import INTENT_TOOLSETS, Intent, classify_intent\n\nlogger = logging.getLogger(__name__)\n\nTOOLSET_DESCRIPTIONS: Dict[str, str] = {\n \"terminal\": \"Run shell commands, install packages, manage processes\",\n \"web\": \"Search and extract content from web pages\",\n \"file\": \"Read, write, search, and manage files\",\n \"memory\": \"Store and retrieve long-term memories, manage recall\",\n \"skills\": \"Create, edit, view, and manage Hermes skills\",\n \"browser\": \"Navigate, click, fill forms, interact with web pages\",\n \"image_gen\": \"Generate images via DALL-E or Stable Diffusion\",\n \"video_gen\": \"Generate videos\",\n \"video\": \"Analyze or generate video content\",\n \"vision\": \"Analyze images — screenshots, photos, diagrams\",\n \"git\": \"Git operations — commit, push, pull, branch, clone\",\n \"code_execution\": \"Run sandboxed code (Python, Node, shell) safely\",\n \"kanban\": \"Kanban board — create, assign, move tasks\",\n \"context_engine\": \"Context-aware processing with custom rules\",\n \"mcp\": \"MCP server tool discovery and interaction\",\n \"approval\": \"Approve dangerous shell commands mid-execution\",\n \"tts\": \"Text-to-speech audio generation\",\n \"acp\": \"ACP filesystem and edit operations\",\n \"discord\": \"Discord bot — send messages, manage channels\",\n \"slack\": \"Slack integration — messages, reactions, threads\",\n \"telegram\": \"Telegram bot integration\",\n \"delegation\": \"Delegate tasks to subagents\",\n \"messaging\": \"Send messages through configured gateway platforms\",\n}\nTOOLSET_INTENT_RULES: List[tuple[str, Set[str]]] = [\n (r\"\\b(reddit|subreddit|r/[A-Za-z0-9_]+)\\b\", {\"web\", \"browser\"}),\n (r\"\\b(open|click|browse|browser|web\\s?page|screenshot|log\\s?in|form|navigate)\\b|https?://\", {\"browser\", \"web\"}),\n (r\"\\b(search|look\\s?up|google|web|online|current|latest|today|news|weather|price|schedule)\\b\", {\"web\"}),\n (r\"\\b(file|folder|path|read|write|edit|save|delete|rename|copy|move|upload|download)\\b\", {\"file\"}),\n (r\"\\b(run|command|terminal|shell|install|build|test|pytest|npm|python|powershell|process)\\b\", {\"terminal\"}),\n (r\"\\b(git|commit|branch|checkout|diff|merge|push|pull|repo|repository|pr|pull request)\\b\", {\"git\", \"file\", \"terminal\"}),\n (r\"\\b(code|debug|fix|implement|refactor|compile|lint)\\b\", {\"file\", \"terminal\"}),\n (r\"\\b(image|picture|photo|diagram|screenshot|vision|analy[sz]e this)\\b\", {\"vision\"}),\n (r\"\\b(generate|create|draw|make).{0,40}\\b(image|picture|photo|art)\\b\", {\"image_gen\"}),\n (r\"\\b(video|movie|clip|animation)\\b\", {\"video_gen\", \"video\"}),\n (r\"\\b(remember|memory|recall|save this|store this)\\b\", {\"memory\"}),\n (r\"\\b(skill|skills)\\b\", {\"skills\"}),\n (r\"\\b(delegate|subagent|sub-agent|multi-agent|worker agent)\\b\", {\"delegation\"}),\n (r\"\\b(discord)\\b\", {\"discord\", \"messaging\"}),\n (r\"\\b(slack)\\b\", {\"slack\", \"messaging\"}),\n (r\"\\b(telegram)\\b\", {\"telegram\", \"messaging\"}),\n (r\"\\b(speak|say aloud|tts|voice)\\b\", {\"tts\"}),\n]\nACTION_HINT_RE = re.compile(\n r\"\\b(\"\n r\"access|analy[sz]e|browse|build|call|change|check|click|commit|create|debug|\"\n r\"download|edit|find|fix|generate|install|look\\s?up|make|modify|open|read|\"\n r\"remember|repair|research|run|save|screenshot|search|send|test|update|write\"\n r\")\\b|https?://\",\n re.IGNORECASE,\n)\nPLAIN_ANSWER_RE = re.compile(\n r\"\\b(\"\n r\"what is|what are|who is|who are|why|how does|how do|explain|define|\"\n r\"compare|difference between|summari[sz]e concept|tell me about\"\n r\")\\b\",\n re.IGNORECASE,\n)\nROUTER_SYSTEM_PROMPT = \"\"\"You are a tool-router classifier. Given a user message and a list of available toolsets with descriptions, predict which toolsets the AI assistant will need to respond.\n\nRules:\n1. Return ONLY JSON: {\"toolsets\": [\"name\"], \"confidence\": 0.0}.\n2. confidence must be a number from 0.0 to 1.0.\n3. Include ONLY toolsets directly relevant to the user's request.\n4. Do not include terminal, file, or web unless the request actually needs them.\n5. For ordinary questions that can be answered without tools, return an empty array.\n6. If the message is ambiguous or uncertain, return \"all\" as the only toolset.\n7. Be conservative; only include toolsets you are confident are needed.\nExample response:\n{{\"toolsets\": [\"terminal\", \"file\"], \"confidence\": 0.96}}\n\nAvailable toolsets:\n{toolset_descriptions}\n\nUser message: {user_message}\n\"\"\"\ndef _get_router_client(\n router_model: str,\n router_provider: str = \"deepseek\",\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n):\n \"\"\"Obtain an OpenAI-compatible client for the router.\n\n Supports:\n - deepseek: uses DEEPSEEK_API_KEY, standard Chat Completions API\n - openrouter: uses OPENROUTER_API_KEY, standard Chat Completions API\n - openai-codex: uses OAuth credential pool (Responses API — not yet implemented)\n\n Returns (client, model_name) or (None, None) on failure.\n \"\"\"\n try:\n from openai import OpenAI\n\n if router_provider == \"deepseek\":\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no DEEPSEEK_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://api.deepseek.com\",\n timeout=8,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openrouter\":\n api_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no OPENROUTER_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://openrouter.ai/api/v1\",\n timeout=8,\n max_retries=0,\n default_headers={\n \"HTTP-Referer\": \"https://github.com/hermes-agent/hermes-token-router\",\n \"X-Title\": \"Hermes Tool Router\",\n },\n )\n return client, router_model\n\n elif router_provider == \"custom\":\n if not custom_base_url:\n logger.debug(\"%s: custom classifier requires base_url\", PLUGIN_NAME)\n return None, None\n api_key = os.environ.get(custom_api_key_env, \"\") if custom_api_key_env else \"local\"\n if custom_api_key_env and not api_key:\n logger.debug(\n \"%s: custom classifier key env %s is unset\",\n PLUGIN_NAME,\n custom_api_key_env,\n )\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=custom_base_url,\n timeout=2,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openai-codex\":\n logger.warning(\n \"%s: router_provider=openai-codex is disabled here because the \"\n \"Codex backend requires the Responses API; using full toolset fallback\",\n PLUGIN_NAME,\n )\n return None, None\n\n else:\n logger.warning(\n \"%s: unknown router_provider '%s'\", PLUGIN_NAME, router_provider\n )\n return None, None\n\n except Exception as exc:\n logger.warning(\n \"%s: failed to initialize router client: %s\",\n PLUGIN_NAME,\n exc,\n )\n return None, None\ndef _get_available_toolsets() -> Set[str]:\n \"\"\"Return all known toolset names from the registry.\"\"\"\n try:\n from tools.registry import registry\n return set(registry.get_registered_toolset_names())\n except Exception:\n return set()\ndef _build_toolset_description_block(available: Set[str]) -> str:\n \"\"\"Build a formatted string of toolset descriptions for the router prompt.\"\"\"\n lines = []\n for name in sorted(available):\n desc = TOOLSET_DESCRIPTIONS.get(name, f\"Tools for {name}\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\ndef _predict_toolsets_by_rules(\n user_message: str,\n available_toolsets: Set[str],\n) -> tuple[Optional[Set[str]], str]:\n \"\"\"Fast deterministic routing for obvious intents.\n\n Returns (toolsets, reason). toolsets=None means the LLM router should decide.\n An empty set is a valid prediction for plain-answer turns.\n \"\"\"\n text = (user_message or \"\").strip()\n lowered = text.lower()\n if not lowered:\n return None, \"empty\"\n\n intent_result = classify_intent(text)\n if intent_result.intents == frozenset({Intent.ANSWER_ONLY}):\n return set(), f\"intent:{intent_result.reason_code}\"\n if Intent.FULL_SURFACE not in intent_result.intents:\n intent_toolsets: Set[str] = set()\n for intent in intent_result.intents:\n intent_toolsets.update(INTENT_TOOLSETS.get(intent, frozenset()))\n intent_toolsets &= available_toolsets\n if intent_toolsets:\n return intent_toolsets, f\"intent:{intent_result.reason_code}\"\n\n matched: Set[str] = set()\n matched_rules = 0\n for pattern, toolsets in TOOLSET_INTENT_RULES:\n if re.search(pattern, lowered, re.IGNORECASE):\n matched.update(toolsets)\n matched_rules += 1\n\n matched &= available_toolsets\n if matched:\n return matched, f\"rules:{matched_rules}\"\n\n words = re.findall(r\"[a-z0-9']+\", lowered)\n if len(words) <= 4 and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_short\"\n\n if PLAIN_ANSWER_RE.search(lowered) and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_answer\"\n\n if not ACTION_HINT_RE.search(lowered) and len(text) <= 240:\n return set(), \"plain_default\"\n\n return None, \"needs_llm\"\ndef _extract_confidence(result: Any) -> Optional[float]:\n \"\"\"Extract confidence from router output; missing confidence is unknown.\"\"\"\n if not isinstance(result, dict):\n return None\n\n for key in (\"confidence\", \"probability\", \"prob\", \"score\", \"confidence_score\"):\n raw = result.get(key)\n if raw is None or isinstance(raw, bool):\n continue\n\n parsed: Optional[float]\n if isinstance(raw, (int, float)):\n parsed = float(raw)\n elif isinstance(raw, str):\n try:\n parsed = float(raw.strip().replace(\"%\", \"\"))\n except ValueError:\n parsed = None\n else:\n parsed = None\n\n if parsed is None or parsed < 0:\n continue\n\n if parsed > 1 and parsed <= 100:\n parsed = parsed / 100.0\n\n return parsed\n\n return None\ndef _predict_toolsets_via_llm(\n user_message: str,\n available_toolsets: Set[str],\n router_model: str,\n confidence_threshold: float,\n router_provider: str = \"deepseek\",\n hard_timeout_seconds: float = 1.2,\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n) -> Optional[Set[str]]:\n \"\"\"Use the router model to predict which toolsets are needed.\n\n Returns a reduced set of toolsets, or None to use full set (bypass).\n On any error, returns None for safe fallback.\n \"\"\"\n try:\n client, model = _get_router_client(\n router_model,\n router_provider,\n custom_base_url,\n custom_api_key_env,\n )\n if client is None:\n logger.debug(\n \"%s: router client unavailable — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n\n toolset_desc = _build_toolset_description_block(available_toolsets)\n \n prompt = ROUTER_SYSTEM_PROMPT.format(\n toolset_descriptions=toolset_desc,\n user_message=user_message[:1500], # Limit message length for router\n )\n\n start = time.monotonic()\n logger.debug(\n \"%s: calling chat.completions.create provider=%s model=%s\",\n PLUGIN_NAME,\n router_provider,\n model,\n )\n\n def invoke_classifier():\n return client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"system\", \"content\": prompt}],\n temperature=0.0,\n max_tokens=200,\n )\n\n try:\n completed, response = call_with_hard_timeout(invoke_classifier, hard_timeout_seconds)\n if not completed or response is None:\n logger.warning(\n \"%s: router prediction timed out after %.2fs — full set fallback\",\n PLUGIN_NAME,\n hard_timeout_seconds,\n )\n return None\n except Exception as thread_exc:\n logger.warning(\n \"%s: router API call failed: %s — full set fallback\",\n PLUGIN_NAME, thread_exc,\n )\n return None\n elapsed = time.monotonic() - start\n\n content = response.choices[0].message.content or \"\"\n \n logger.debug(\n \"%s: router model=%s responded in %.2fs: %.100s\",\n PLUGIN_NAME,\n model,\n elapsed,\n content.strip(),\n )\n\n # Parse JSON response\n content = content.strip()\n # Remove markdown code fences if present\n if content.startswith(\"```\"):\n content = content.split(\"\\n\", 1)[-1]\n content = content.rsplit(\"```\", 1)[0]\n content = content.strip()\n\n result = json.loads(content)\n predicted_list = result.get(\"toolsets\", [])\n\n confidence = _extract_confidence(result)\n if confidence is None:\n logger.info(\"%s: router omitted confidence; keeping full toolset\", PLUGIN_NAME)\n return None\n if confidence < confidence_threshold:\n logger.info(\n \"%s: router confidence %.3f below threshold %.3f; keeping full toolset\",\n PLUGIN_NAME,\n confidence,\n confidence_threshold,\n )\n return None\n\n if not isinstance(predicted_list, list):\n logger.debug(\n \"%s: router returned non-list: %s\",\n PLUGIN_NAME, result,\n )\n return None\n\n # Handle \"all\" bypass signal\n if \"all\" in predicted_list:\n logger.debug(\n \"%s: router signaled 'all' — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # Filter to only known toolsets. An empty list is a valid no-tool prediction.\n predicted = set(predicted_list) & available_toolsets\n\n if not predicted and predicted_list:\n logger.debug(\n \"%s: router returned empty set after filtering — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # If predicted is the same as full set, no reduction needed\n if predicted == available_toolsets:\n logger.debug(\n \"%s: router predicted all available toolsets — no reduction\",\n PLUGIN_NAME,\n )\n return None\n\n logger.info(\n \"%s: router model=%s predicted %d toolsets (from %d) in %.2fs: %s\",\n PLUGIN_NAME,\n model,\n len(predicted),\n len(available_toolsets),\n elapsed,\n sorted(predicted),\n )\n\n return predicted\n\n except json.JSONDecodeError as exc:\n logger.warning(\n \"%s: failed to parse router response: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n except Exception as exc:\n logger.warning(\n \"%s: router prediction failed: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n"}, "32f7112a7cf416b3e5c46969c76c3959c9afa3db": {"t": 1785753267.148366, "n": 14697, "h": "11a062163681d8de", "c": "\"\"\"Toolset prediction policy for hermes-token-router.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom typing import Any, Optional, Set\n\ntry:\n from .config import PLUGIN_NAME\nexcept ImportError: # pragma: no cover - direct loader fallback\n from config import PLUGIN_NAME\n\nlogger = logging.getLogger(__name__)\n\nTOOLSET_DESCRIPTIONS: Dict[str, str] = {\n \"terminal\": \"Run shell commands, install packages, manage processes\",\n \"web\": \"Search and extract content from web pages\",\n \"file\": \"Read, write, search, and manage files\",\n \"memory\": \"Store and retrieve long-term memories, manage recall\",\n \"skills\": \"Create, edit, view, and manage Hermes skills\",\n \"browser\": \"Navigate, click, fill forms, interact with web pages\",\n \"image_gen\": \"Generate images via DALL-E or Stable Diffusion\",\n \"video_gen\": \"Generate videos\",\n \"video\": \"Analyze or generate video content\",\n \"vision\": \"Analyze images — screenshots, photos, diagrams\",\n \"git\": \"Git operations — commit, push, pull, branch, clone\",\n \"code_execution\": \"Run sandboxed code (Python, Node, shell) safely\",\n \"kanban\": \"Kanban board — create, assign, move tasks\",\n \"context_engine\": \"Context-aware processing with custom rules\",\n \"mcp\": \"MCP server tool discovery and interaction\",\n \"approval\": \"Approve dangerous shell commands mid-execution\",\n \"tts\": \"Text-to-speech audio generation\",\n \"acp\": \"ACP filesystem and edit operations\",\n \"discord\": \"Discord bot — send messages, manage channels\",\n \"slack\": \"Slack integration — messages, reactions, threads\",\n \"telegram\": \"Telegram bot integration\",\n \"delegation\": \"Delegate tasks to subagents\",\n \"messaging\": \"Send messages through configured gateway platforms\",\n}\nTOOLSET_INTENT_RULES: List[tuple[str, Set[str]]] = [\n (r\"\\b(reddit|subreddit|r/[A-Za-z0-9_]+)\\b\", {\"web\", \"browser\"}),\n (r\"\\b(open|click|browse|browser|web\\s?page|screenshot|log\\s?in|form|navigate)\\b|https?://\", {\"browser\", \"web\"}),\n (r\"\\b(search|look\\s?up|google|web|online|current|latest|today|news|weather|price|schedule)\\b\", {\"web\"}),\n (r\"\\b(file|folder|path|read|write|edit|save|delete|rename|copy|move|upload|download)\\b\", {\"file\"}),\n (r\"\\b(run|command|terminal|shell|install|build|test|pytest|npm|python|powershell|process)\\b\", {\"terminal\"}),\n (r\"\\b(git|commit|branch|checkout|diff|merge|push|pull|repo|repository|pr|pull request)\\b\", {\"git\", \"file\", \"terminal\"}),\n (r\"\\b(code|debug|fix|implement|refactor|compile|lint)\\b\", {\"file\", \"terminal\"}),\n (r\"\\b(image|picture|photo|diagram|screenshot|vision|analy[sz]e this)\\b\", {\"vision\"}),\n (r\"\\b(generate|create|draw|make).{0,40}\\b(image|picture|photo|art)\\b\", {\"image_gen\"}),\n (r\"\\b(video|movie|clip|animation)\\b\", {\"video_gen\", \"video\"}),\n (r\"\\b(remember|memory|recall|save this|store this)\\b\", {\"memory\"}),\n (r\"\\b(skill|skills)\\b\", {\"skills\"}),\n (r\"\\b(delegate|subagent|sub-agent|multi-agent|worker agent)\\b\", {\"delegation\"}),\n (r\"\\b(discord)\\b\", {\"discord\", \"messaging\"}),\n (r\"\\b(slack)\\b\", {\"slack\", \"messaging\"}),\n (r\"\\b(telegram)\\b\", {\"telegram\", \"messaging\"}),\n (r\"\\b(speak|say aloud|tts|voice)\\b\", {\"tts\"}),\n]\nACTION_HINT_RE = re.compile(\n r\"\\b(\"\n r\"access|analy[sz]e|browse|build|call|change|check|click|commit|create|debug|\"\n r\"download|edit|find|fix|generate|install|look\\s?up|make|modify|open|read|\"\n r\"remember|repair|research|run|save|screenshot|search|send|test|update|write\"\n r\")\\b|https?://\",\n re.IGNORECASE,\n)\nPLAIN_ANSWER_RE = re.compile(\n r\"\\b(\"\n r\"what is|what are|who is|who are|why|how does|how do|explain|define|\"\n r\"compare|difference between|summari[sz]e concept|tell me about\"\n r\")\\b\",\n re.IGNORECASE,\n)\nROUTER_SYSTEM_PROMPT = \"\"\"You are a tool-router classifier. Given a user message and a list of available toolsets with descriptions, predict which toolsets the AI assistant will need to respond.\n\nRules:\n1. Return ONLY a JSON object with a single key \"toolsets\" containing an array of toolset names.\n2. Include ONLY toolsets directly relevant to the user's request.\n3. Do not include terminal, file, or web unless the request actually needs them.\n4. For ordinary questions that can be answered without tools, return an empty array.\n5. If the message is ambiguous or uncertain, return \"all\" as the only element.\n6. Be conservative; only include toolsets you are confident are needed.\nExample response:\n{{\"toolsets\": [\"terminal\", \"file\", \"web\", \"git\"]}}\n\nAvailable toolsets:\n{toolset_descriptions}\n\nUser message: {user_message}\n\"\"\"\ndef _get_router_client(router_model: str, router_provider: str = \"deepseek\"):\n \"\"\"Obtain an OpenAI-compatible client for the router.\n\n Supports:\n - deepseek: uses DEEPSEEK_API_KEY, standard Chat Completions API\n - openrouter: uses OPENROUTER_API_KEY, standard Chat Completions API\n - openai-codex: uses OAuth credential pool (Responses API — not yet implemented)\n\n Returns (client, model_name) or (None, None) on failure.\n \"\"\"\n try:\n from openai import OpenAI\n\n if router_provider == \"deepseek\":\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no DEEPSEEK_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://api.deepseek.com\",\n timeout=8,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openrouter\":\n api_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no OPENROUTER_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://openrouter.ai/api/v1\",\n timeout=8,\n max_retries=0,\n default_headers={\n \"HTTP-Referer\": \"https://github.com/hermes-agent/hermes-token-router\",\n \"X-Title\": \"Hermes Tool Router\",\n },\n )\n return client, router_model\n\n elif router_provider == \"openai-codex\":\n logger.warning(\n \"%s: router_provider=openai-codex is disabled here because the \"\n \"Codex backend requires the Responses API; using full toolset fallback\",\n PLUGIN_NAME,\n )\n return None, None\n\n else:\n logger.warning(\n \"%s: unknown router_provider '%s'\", PLUGIN_NAME, router_provider\n )\n return None, None\n\n except Exception as exc:\n logger.warning(\n \"%s: failed to initialize router client: %s\",\n PLUGIN_NAME,\n exc,\n )\n return None, None\ndef _get_available_toolsets() -> Set[str]:\n \"\"\"Return all known toolset names from the registry.\"\"\"\n try:\n from tools.registry import registry\n return set(registry.get_registered_toolset_names())\n except Exception:\n return set()\ndef _build_toolset_description_block(available: Set[str]) -> str:\n \"\"\"Build a formatted string of toolset descriptions for the router prompt.\"\"\"\n lines = []\n for name in sorted(available):\n desc = TOOLSET_DESCRIPTIONS.get(name, f\"Tools for {name}\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\ndef _predict_toolsets_by_rules(\n user_message: str,\n available_toolsets: Set[str],\n) -> tuple[Optional[Set[str]], str]:\n \"\"\"Fast deterministic routing for obvious intents.\n\n Returns (toolsets, reason). toolsets=None means the LLM router should decide.\n An empty set is a valid prediction for plain-answer turns.\n \"\"\"\n text = (user_message or \"\").strip()\n lowered = text.lower()\n if not lowered:\n return None, \"empty\"\n\n matched: Set[str] = set()\n matched_rules = 0\n for pattern, toolsets in TOOLSET_INTENT_RULES:\n if re.search(pattern, lowered, re.IGNORECASE):\n matched.update(toolsets)\n matched_rules += 1\n\n matched &= available_toolsets\n if matched:\n return matched, f\"rules:{matched_rules}\"\n\n words = re.findall(r\"[a-z0-9']+\", lowered)\n if len(words) <= 4 and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_short\"\n\n if PLAIN_ANSWER_RE.search(lowered) and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_answer\"\n\n if not ACTION_HINT_RE.search(lowered) and len(text) <= 240:\n return set(), \"plain_default\"\n\n return None, \"needs_llm\"\ndef _extract_confidence(result: Any) -> float:\n \"\"\"Extract confidence from router output; return 1.0 when missing.\"\"\"\n if not isinstance(result, dict):\n return 1.0\n\n for key in (\"confidence\", \"probability\", \"prob\", \"score\", \"confidence_score\"):\n raw = result.get(key)\n if raw is None or isinstance(raw, bool):\n continue\n\n parsed: Optional[float]\n if isinstance(raw, (int, float)):\n parsed = float(raw)\n elif isinstance(raw, str):\n try:\n parsed = float(raw.strip().replace(\"%\", \"\"))\n except ValueError:\n parsed = None\n else:\n parsed = None\n\n if parsed is None or parsed < 0:\n continue\n\n if parsed > 1 and parsed <= 100:\n parsed = parsed / 100.0\n\n return parsed\n\n return 1.0\ndef _predict_toolsets_via_llm(\n user_message: str,\n available_toolsets: Set[str],\n router_model: str,\n confidence_threshold: float,\n router_provider: str = \"deepseek\",\n) -> Optional[Set[str]]:\n \"\"\"Use the router model to predict which toolsets are needed.\n\n Returns a reduced set of toolsets, or None to use full set (bypass).\n On any error, returns None for safe fallback.\n \"\"\"\n try:\n client, model = _get_router_client(router_model, router_provider)\n if client is None:\n logger.debug(\n \"%s: router client unavailable — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n\n toolset_desc = _build_toolset_description_block(available_toolsets)\n \n prompt = ROUTER_SYSTEM_PROMPT.format(\n toolset_descriptions=toolset_desc,\n user_message=user_message[:1500], # Limit message length for router\n )\n\n start = time.monotonic()\n logger.debug(\n \"%s: calling chat.completions.create provider=%s model=%s\",\n PLUGIN_NAME,\n router_provider,\n model,\n )\n\n # Wrap in a thread with a hard timeout — prevent any hang\n from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError\n with ThreadPoolExecutor(max_workers=1) as executor:\n future = executor.submit(\n client.chat.completions.create,\n model=model,\n messages=[{\"role\": \"system\", \"content\": prompt}],\n temperature=0.0,\n max_tokens=200,\n )\n try:\n response = future.result(timeout=8)\n except FutureTimeoutError:\n logger.warning(\n \"%s: router prediction timed out after 8s — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n except Exception as thread_exc:\n logger.warning(\n \"%s: router API call failed: %s — full set fallback\",\n PLUGIN_NAME, thread_exc,\n )\n return None\n elapsed = time.monotonic() - start\n\n content = response.choices[0].message.content or \"\"\n \n logger.debug(\n \"%s: router model=%s responded in %.2fs: %.100s\",\n PLUGIN_NAME,\n model,\n elapsed,\n content.strip(),\n )\n\n # Parse JSON response\n content = content.strip()\n # Remove markdown code fences if present\n if content.startswith(\"```\"):\n content = content.split(\"\\n\", 1)[-1]\n content = content.rsplit(\"```\", 1)[0]\n content = content.strip()\n\n result = json.loads(content)\n predicted_list = result.get(\"toolsets\", [])\n\n confidence = _extract_confidence(result)\n if confidence < confidence_threshold:\n logger.info(\n \"%s: router confidence %.3f below threshold %.3f; keeping full toolset\",\n PLUGIN_NAME,\n confidence,\n confidence_threshold,\n )\n return None\n\n if not isinstance(predicted_list, list):\n logger.debug(\n \"%s: router returned non-list: %s\",\n PLUGIN_NAME, result,\n )\n return None\n\n # Handle \"all\" bypass signal\n if \"all\" in predicted_list:\n logger.debug(\n \"%s: router signaled 'all' — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # Filter to only known toolsets. An empty list is a valid no-tool prediction.\n predicted = set(predicted_list) & available_toolsets\n\n if not predicted and predicted_list:\n logger.debug(\n \"%s: router returned empty set after filtering — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # If predicted is the same as full set, no reduction needed\n if predicted == available_toolsets:\n logger.debug(\n \"%s: router predicted all available toolsets — no reduction\",\n PLUGIN_NAME,\n )\n return None\n\n logger.info(\n \"%s: router model=%s predicted %d toolsets (from %d) in %.2fs: %s\",\n PLUGIN_NAME,\n model,\n len(predicted),\n len(available_toolsets),\n elapsed,\n sorted(predicted),\n )\n\n return predicted\n\n except json.JSONDecodeError as exc:\n logger.warning(\n \"%s: failed to parse router response: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n except Exception as exc:\n logger.warning(\n \"%s: router prediction failed: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n"}, "c35518a0651b549b1cdb4d5aca690e32ee7943c2": {"t": 1786068249.5802755, "n": 16713, "h": "b36cde29c9303899", "c": "\"\"\"Toolset prediction policy for hermes-token-router.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport re\nimport time\nfrom typing import Any, Dict, List, Optional, Set\n\ntry:\n from .config import PLUGIN_NAME\n from .classifier import call_with_hard_timeout\n from .intent import INTENT_TOOLSETS, Intent, classify_intent\nexcept ImportError: # pragma: no cover - direct loader fallback\n from config import PLUGIN_NAME\n from classifier import call_with_hard_timeout\n from intent import INTENT_TOOLSETS, Intent, classify_intent\n\nlogger = logging.getLogger(__name__)\n\nTOOLSET_DESCRIPTIONS: Dict[str, str] = {\n \"terminal\": \"Run shell commands, install packages, manage processes\",\n \"web\": \"Search and extract content from web pages\",\n \"file\": \"Read, write, search, and manage files\",\n \"memory\": \"Store and retrieve long-term memories, manage recall\",\n \"skills\": \"Create, edit, view, and manage Hermes skills\",\n \"browser\": \"Navigate, click, fill forms, interact with web pages\",\n \"image_gen\": \"Generate images via DALL-E or Stable Diffusion\",\n \"video_gen\": \"Generate videos\",\n \"video\": \"Analyze or generate video content\",\n \"vision\": \"Analyze images — screenshots, photos, diagrams\",\n \"git\": \"Git operations — commit, push, pull, branch, clone\",\n \"code_execution\": \"Run sandboxed code (Python, Node, shell) safely\",\n \"kanban\": \"Kanban board — create, assign, move tasks\",\n \"context_engine\": \"Context-aware processing with custom rules\",\n \"mcp\": \"MCP server tool discovery and interaction\",\n \"approval\": \"Approve dangerous shell commands mid-execution\",\n \"tts\": \"Text-to-speech audio generation\",\n \"acp\": \"ACP filesystem and edit operations\",\n \"discord\": \"Discord bot — send messages, manage channels\",\n \"slack\": \"Slack integration — messages, reactions, threads\",\n \"telegram\": \"Telegram bot integration\",\n \"delegation\": \"Delegate tasks to subagents\",\n \"messaging\": \"Send messages through configured gateway platforms\",\n}\nTOOLSET_INTENT_RULES: List[tuple[str, Set[str]]] = [\n (r\"\\b(reddit|subreddit|r/[A-Za-z0-9_]+)\\b\", {\"web\", \"browser\"}),\n (r\"\\b(open|click|browse|browser|web\\s?page|screenshot|log\\s?in|form|navigate)\\b|https?://\", {\"browser\", \"web\"}),\n (r\"\\b(search|look\\s?up|google|web|online|current|latest|today|news|weather|price|schedule)\\b\", {\"web\"}),\n (r\"\\b(file|folder|path|read|write|edit|save|delete|rename|copy|move|upload|download)\\b\", {\"file\"}),\n (r\"\\b(run|command|terminal|shell|install|build|test|pytest|npm|python|powershell|process)\\b\", {\"terminal\"}),\n (r\"\\b(git|commit|branch|checkout|diff|merge|push|pull|repo|repository|pr|pull request)\\b\", {\"git\", \"file\", \"terminal\"}),\n (r\"\\b(code|debug|fix|implement|refactor|compile|lint)\\b\", {\"file\", \"terminal\"}),\n (r\"\\b(image|picture|photo|diagram|screenshot|vision|analy[sz]e this)\\b\", {\"vision\"}),\n (r\"\\b(generate|create|draw|make).{0,40}\\b(image|picture|photo|art)\\b\", {\"image_gen\"}),\n (r\"\\b(video|movie|clip|animation)\\b\", {\"video_gen\", \"video\"}),\n (r\"\\b(remember|memory|recall|save this|store this)\\b\", {\"memory\"}),\n (r\"\\b(skill|skills)\\b\", {\"skills\"}),\n (r\"\\b(delegate|subagent|sub-agent|multi-agent|worker agent)\\b\", {\"delegation\"}),\n (r\"\\b(discord)\\b\", {\"discord\", \"messaging\"}),\n (r\"\\b(slack)\\b\", {\"slack\", \"messaging\"}),\n (r\"\\b(telegram)\\b\", {\"telegram\", \"messaging\"}),\n (r\"\\b(speak|say aloud|tts|voice)\\b\", {\"tts\"}),\n]\nACTION_HINT_RE = re.compile(\n r\"\\b(\"\n r\"access|analy[sz]e|browse|build|call|change|check|click|commit|create|debug|\"\n r\"download|edit|find|fix|generate|install|look\\s?up|make|modify|open|read|\"\n r\"remember|repair|research|run|save|screenshot|search|send|test|update|write\"\n r\")\\b|https?://\",\n re.IGNORECASE,\n)\nPLAIN_ANSWER_RE = re.compile(\n r\"\\b(\"\n r\"what is|what are|who is|who are|why|how does|how do|explain|define|\"\n r\"compare|difference between|summari[sz]e concept|tell me about\"\n r\")\\b\",\n re.IGNORECASE,\n)\nROUTER_SYSTEM_PROMPT = \"\"\"You are a tool-router classifier. Given a user message and a list of available toolsets with descriptions, predict which toolsets the AI assistant will need to respond.\n\nRules:\n1. Return ONLY JSON: {{\"toolsets\": [\"name\"], \"confidence\": 0.0}}.\n2. confidence must be a number from 0.0 to 1.0.\n3. Include ONLY toolsets directly relevant to the user's request.\n4. Do not include terminal, file, or web unless the request actually needs them.\n5. For ordinary questions that can be answered without tools, return an empty array.\n6. If the message is ambiguous or uncertain, return \"all\" as the only toolset.\n7. Be conservative; only include toolsets you are confident are needed.\n8. Personal calendar, itinerary, reservation, or flight-detail lookups require \"skills\" and \"terminal\" when those toolsets are available.\nExample response:\n{{\"toolsets\": [\"terminal\", \"file\"], \"confidence\": 0.96}}\n\nAvailable toolsets:\n{toolset_descriptions}\n\nUser message: {user_message}\n\"\"\"\ndef _get_router_client(\n router_model: str,\n router_provider: str = \"deepseek\",\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n):\n \"\"\"Obtain an OpenAI-compatible client for the router.\n\n Supports:\n - deepseek: uses DEEPSEEK_API_KEY, standard Chat Completions API\n - openrouter: uses OPENROUTER_API_KEY, standard Chat Completions API\n - openai-codex: uses OAuth credential pool (Responses API — not yet implemented)\n\n Returns (client, model_name) or (None, None) on failure.\n \"\"\"\n try:\n from openai import OpenAI\n\n if router_provider == \"deepseek\":\n api_key = os.environ.get(\"DEEPSEEK_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no DEEPSEEK_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://api.deepseek.com\",\n timeout=8,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openrouter\":\n api_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if not api_key:\n logger.debug(\"%s: no OPENROUTER_API_KEY available\", PLUGIN_NAME)\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=\"https://openrouter.ai/api/v1\",\n timeout=8,\n max_retries=0,\n default_headers={\n \"HTTP-Referer\": \"https://github.com/hermes-agent/hermes-token-router\",\n \"X-Title\": \"Hermes Tool Router\",\n },\n )\n return client, router_model\n\n elif router_provider == \"custom\":\n if not custom_base_url:\n logger.debug(\"%s: custom classifier requires base_url\", PLUGIN_NAME)\n return None, None\n api_key = os.environ.get(custom_api_key_env, \"\") if custom_api_key_env else \"local\"\n if custom_api_key_env and not api_key:\n logger.debug(\n \"%s: custom classifier key env %s is unset\",\n PLUGIN_NAME,\n custom_api_key_env,\n )\n return None, None\n client = OpenAI(\n api_key=api_key,\n base_url=custom_base_url,\n timeout=2,\n max_retries=0,\n )\n return client, router_model\n\n elif router_provider == \"openai-codex\":\n logger.warning(\n \"%s: router_provider=openai-codex is disabled here because the \"\n \"Codex backend requires the Responses API; using full toolset fallback\",\n PLUGIN_NAME,\n )\n return None, None\n\n else:\n logger.warning(\n \"%s: unknown router_provider '%s'\", PLUGIN_NAME, router_provider\n )\n return None, None\n\n except Exception as exc:\n logger.warning(\n \"%s: failed to initialize router client: %s\",\n PLUGIN_NAME,\n exc,\n )\n return None, None\ndef _get_available_toolsets() -> Set[str]:\n \"\"\"Return all known toolset names from the registry.\"\"\"\n try:\n from tools.registry import registry\n return set(registry.get_registered_toolset_names())\n except Exception:\n return set()\ndef _build_toolset_description_block(available: Set[str]) -> str:\n \"\"\"Build a formatted string of toolset descriptions for the router prompt.\"\"\"\n lines = []\n for name in sorted(available):\n desc = TOOLSET_DESCRIPTIONS.get(name, f\"Tools for {name}\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\ndef _predict_toolsets_by_rules(\n user_message: str,\n available_toolsets: Set[str],\n) -> tuple[Optional[Set[str]], str]:\n \"\"\"Fast deterministic routing for obvious intents.\n\n Returns (toolsets, reason). toolsets=None means the LLM router should decide.\n An empty set is a valid prediction for plain-answer turns.\n \"\"\"\n text = (user_message or \"\").strip()\n lowered = text.lower()\n if not lowered:\n return None, \"empty\"\n\n intent_result = classify_intent(text)\n if intent_result.intents == frozenset({Intent.ANSWER_ONLY}):\n return set(), f\"intent:{intent_result.reason_code}\"\n if Intent.FULL_SURFACE not in intent_result.intents:\n intent_toolsets: Set[str] = set()\n for intent in intent_result.intents:\n intent_toolsets.update(INTENT_TOOLSETS.get(intent, frozenset()))\n intent_toolsets &= available_toolsets\n if intent_toolsets:\n return intent_toolsets, f\"intent:{intent_result.reason_code}\"\n\n matched: Set[str] = set()\n matched_rules = 0\n for pattern, toolsets in TOOLSET_INTENT_RULES:\n if re.search(pattern, lowered, re.IGNORECASE):\n matched.update(toolsets)\n matched_rules += 1\n\n matched &= available_toolsets\n if matched:\n return matched, f\"rules:{matched_rules}\"\n\n words = re.findall(r\"[a-z0-9']+\", lowered)\n if len(words) <= 4 and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_short\"\n\n if PLAIN_ANSWER_RE.search(lowered) and not ACTION_HINT_RE.search(lowered):\n return set(), \"plain_answer\"\n\n if not ACTION_HINT_RE.search(lowered) and len(text) <= 240:\n return set(), \"plain_default\"\n\n return None, \"needs_llm\"\ndef _extract_confidence(result: Any) -> Optional[float]:\n \"\"\"Extract confidence from router output; missing confidence is unknown.\"\"\"\n if not isinstance(result, dict):\n return None\n\n for key in (\"confidence\", \"probability\", \"prob\", \"score\", \"confidence_score\"):\n raw = result.get(key)\n if raw is None or isinstance(raw, bool):\n continue\n\n parsed: Optional[float]\n if isinstance(raw, (int, float)):\n parsed = float(raw)\n elif isinstance(raw, str):\n try:\n parsed = float(raw.strip().replace(\"%\", \"\"))\n except ValueError:\n parsed = None\n else:\n parsed = None\n\n if parsed is None or parsed < 0:\n continue\n\n if parsed > 1 and parsed <= 100:\n parsed = parsed / 100.0\n\n return parsed\n\n return None\ndef _predict_toolsets_via_llm(\n user_message: str,\n available_toolsets: Set[str],\n router_model: str,\n confidence_threshold: float,\n router_provider: str = \"deepseek\",\n hard_timeout_seconds: float = 1.2,\n custom_base_url: str = \"\",\n custom_api_key_env: str = \"\",\n) -> Optional[Set[str]]:\n \"\"\"Use the router model to predict which toolsets are needed.\n\n Returns a reduced set of toolsets, or None to use full set (bypass).\n On any error, returns None for safe fallback.\n \"\"\"\n try:\n client, model = _get_router_client(\n router_model,\n router_provider,\n custom_base_url,\n custom_api_key_env,\n )\n if client is None:\n logger.debug(\n \"%s: router client unavailable — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n\n toolset_desc = _build_toolset_description_block(available_toolsets)\n \n prompt = ROUTER_SYSTEM_PROMPT.format(\n toolset_descriptions=toolset_desc,\n user_message=user_message[:1500], # Limit message length for router\n )\n\n start = time.monotonic()\n logger.debug(\n \"%s: calling chat.completions.create provider=%s model=%s\",\n PLUGIN_NAME,\n router_provider,\n model,\n )\n\n def invoke_classifier():\n return client.chat.completions.create(\n model=model,\n messages=[{\"role\": \"system\", \"content\": prompt}],\n temperature=0.0,\n max_tokens=200,\n )\n\n try:\n completed, response = call_with_hard_timeout(invoke_classifier, hard_timeout_seconds)\n if not completed or response is None:\n logger.warning(\n \"%s: router prediction timed out after %.2fs — full set fallback\",\n PLUGIN_NAME,\n hard_timeout_seconds,\n )\n return None\n except Exception as thread_exc:\n logger.warning(\n \"%s: router API call failed: %s — full set fallback\",\n PLUGIN_NAME, thread_exc,\n )\n return None\n elapsed = time.monotonic() - start\n\n content = response.choices[0].message.content or \"\"\n \n logger.debug(\n \"%s: router model=%s responded in %.2fs: %.100s\",\n PLUGIN_NAME,\n model,\n elapsed,\n content.strip(),\n )\n\n # Parse JSON response\n content = content.strip()\n # Remove markdown code fences if present\n if content.startswith(\"```\"):\n content = content.split(\"\\n\", 1)[-1]\n content = content.rsplit(\"```\", 1)[0]\n content = content.strip()\n\n result = json.loads(content)\n predicted_list = result.get(\"toolsets\", [])\n\n confidence = _extract_confidence(result)\n if confidence is None:\n logger.info(\"%s: router omitted confidence; keeping full toolset\", PLUGIN_NAME)\n return None\n if confidence < confidence_threshold:\n logger.info(\n \"%s: router confidence %.3f below threshold %.3f; keeping full toolset\",\n PLUGIN_NAME,\n confidence,\n confidence_threshold,\n )\n return None\n\n if not isinstance(predicted_list, list):\n logger.debug(\n \"%s: router returned non-list: %s\",\n PLUGIN_NAME, result,\n )\n return None\n\n # Handle \"all\" bypass signal\n if \"all\" in predicted_list:\n logger.debug(\n \"%s: router signaled 'all' — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # Filter to only known toolsets. An empty list is a valid no-tool prediction.\n predicted = set(predicted_list) & available_toolsets\n\n if not predicted and predicted_list:\n logger.debug(\n \"%s: router returned empty set after filtering — full set fallback\",\n PLUGIN_NAME,\n )\n return None\n\n # If predicted is the same as full set, no reduction needed\n if predicted == available_toolsets:\n logger.debug(\n \"%s: router predicted all available toolsets — no reduction\",\n PLUGIN_NAME,\n )\n return None\n\n logger.info(\n \"%s: router model=%s predicted %d toolsets (from %d) in %.2fs: %s\",\n PLUGIN_NAME,\n model,\n len(predicted),\n len(available_toolsets),\n elapsed,\n sorted(predicted),\n )\n\n return predicted\n\n except json.JSONDecodeError as exc:\n logger.warning(\n \"%s: failed to parse router response: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n except Exception as exc:\n logger.warning(\n \"%s: router prediction failed: %s\",\n PLUGIN_NAME, exc,\n )\n return None\n"}}}}