fix: keyword routing fallback — never return zero tools

Previously narrow_tools_by_keywords returned [] when no category matched,
silently disabling all tools for messages that didn't hit a keyword.

Now:
- Falls back to _FALLBACK_TOOLS (web_search, session_search, session_read,
  scratch_read) when no category matches — tools always available for casual use
- Detects explicit "use your tools / look that up / can you check" phrases
  and returns the full role tool list (None = all permitted)
- Return type updated to list[str] | None; orchestrator log handles both

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-16 21:40:17 -04:00
parent 6ca588959d
commit 3eb3ce1146
2 changed files with 37 additions and 15 deletions

View File

@@ -640,11 +640,23 @@ _KEYWORD_CATEGORY_MAP: dict[str, list[str]] = {
}
def classify_tool_categories(message: str) -> list[str]:
"""Return category names whose keywords appear in message (case-insensitive).
# Tools always available as a fallback when no keyword category matches.
# Covers the most common casual needs: quick lookup, session context, scratchpad.
_FALLBACK_TOOLS: list[str] = [
"web_search", "session_search", "session_read", "scratch_read",
]
Empty return means no tool category matched — route as pure chat with zero tool overhead.
"""
# Phrases that signal the user explicitly wants the orchestrator to use tools.
# When matched, the full role tool list is returned rather than a narrow subset.
_EXPLICIT_TOOL_PHRASES: list[str] = [
"use your tools", "use tools", "with tools", "using tools",
"look that up", "can you check", "please check", "go check",
"use the tools", "tools to", "check on that",
]
def classify_tool_categories(message: str) -> list[str]:
"""Return category names whose keywords appear in message (case-insensitive)."""
low = message.lower()
return [cat for cat, kws in _KEYWORD_CATEGORY_MAP.items() if any(kw in low for kw in kws)]
@@ -653,15 +665,15 @@ def narrow_tools_by_keywords(
message: str,
role_tools: list[str] | None,
context_messages: list[dict] | None = None,
) -> list[str]:
) -> list[str] | None:
"""Narrow the active tool list to categories relevant to this message.
Also scans the last assistant message in context_messages — this catches follow-up
patterns like "yes, please do that" where the tool intent was expressed by the assistant
in the prior turn and the user is simply confirming.
Also scans the last assistant message in context_messages — catches follow-up
patterns like "yes, please do that" where tool intent was in the prior turn.
Returns [] if no keywords matched (zero tool overhead).
Returns keyword-matched tools, intersected with role_tools if role_tools is set.
Returns None — explicit tool request detected; caller uses full role tool list.
Returns list — keyword-matched tools (intersected with role_tools if set).
Falls back to _FALLBACK_TOOLS rather than [] when no categories match.
"""
scan_text = message
if context_messages:
@@ -670,9 +682,20 @@ def narrow_tools_by_keywords(
scan_text = scan_text + " " + (m.get("content") or "")
break
low_scan = scan_text.lower()
# Explicit "use your tools" request → return full permitted list
if any(phrase in low_scan for phrase in _EXPLICIT_TOOL_PHRASES):
return role_tools # None means all tools; list means role-scoped set
matched = classify_tool_categories(scan_text)
if not matched:
return []
# No category matched — return minimal fallback rather than empty
fallback = list(_FALLBACK_TOOLS)
if role_tools is not None:
role_set = set(role_tools)
fallback = [t for t in fallback if t in role_set]
return fallback
seen: set[str] = set()
dynamic: list[str] = []