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:
@@ -77,12 +77,11 @@ async def run(
|
||||
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
||||
|
||||
# Keyword routing: narrow schemas to only what this message needs.
|
||||
# Also scans the last assistant turn so follow-ups like "yes, do that" inherit tool context.
|
||||
# Returns [] when no keywords match (zero tool overhead — model responds as plain chat).
|
||||
# Returns None on explicit tool request (full role list), fallback set when no keywords match.
|
||||
effective_tool_list = narrow_tools_by_keywords(task, tool_list, context_messages=session_messages)
|
||||
logger.info(
|
||||
"Keyword routing: %d tools active (role_tools=%s)",
|
||||
len(effective_tool_list),
|
||||
"Keyword routing: %s tools active (role_tools=%s)",
|
||||
len(effective_tool_list) if effective_tool_list is not None else "all",
|
||||
len(tool_list) if tool_list is not None else "all",
|
||||
)
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user