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)
|
effective_confirm = (CONFIRM_REQUIRED - set(_confirm_allow)) | set(_confirm_deny)
|
||||||
|
|
||||||
# Keyword routing: narrow schemas to only what this message needs.
|
# 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 None on explicit tool request (full role list), fallback set when no keywords match.
|
||||||
# Returns [] when no keywords match (zero tool overhead — model responds as plain chat).
|
|
||||||
effective_tool_list = narrow_tools_by_keywords(task, tool_list, context_messages=session_messages)
|
effective_tool_list = narrow_tools_by_keywords(task, tool_list, context_messages=session_messages)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Keyword routing: %d tools active (role_tools=%s)",
|
"Keyword routing: %s tools active (role_tools=%s)",
|
||||||
len(effective_tool_list),
|
len(effective_tool_list) if effective_tool_list is not None else "all",
|
||||||
len(tool_list) if 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]:
|
# Tools always available as a fallback when no keyword category matches.
|
||||||
"""Return category names whose keywords appear in message (case-insensitive).
|
# 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()
|
low = message.lower()
|
||||||
return [cat for cat, kws in _KEYWORD_CATEGORY_MAP.items() if any(kw in low for kw in kws)]
|
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,
|
message: str,
|
||||||
role_tools: list[str] | None,
|
role_tools: list[str] | None,
|
||||||
context_messages: list[dict] | None = None,
|
context_messages: list[dict] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str] | None:
|
||||||
"""Narrow the active tool list to categories relevant to this message.
|
"""Narrow the active tool list to categories relevant to this message.
|
||||||
|
|
||||||
Also scans the last assistant message in context_messages — this catches follow-up
|
Also scans the last assistant message in context_messages — catches follow-up
|
||||||
patterns like "yes, please do that" where the tool intent was expressed by the assistant
|
patterns like "yes, please do that" where tool intent was in the prior turn.
|
||||||
in the prior turn and the user is simply confirming.
|
|
||||||
|
|
||||||
Returns [] if no keywords matched (zero tool overhead).
|
Returns None — explicit tool request detected; caller uses full role tool list.
|
||||||
Returns keyword-matched tools, intersected with role_tools if role_tools is set.
|
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
|
scan_text = message
|
||||||
if context_messages:
|
if context_messages:
|
||||||
@@ -670,9 +682,20 @@ def narrow_tools_by_keywords(
|
|||||||
scan_text = scan_text + " " + (m.get("content") or "")
|
scan_text = scan_text + " " + (m.get("content") or "")
|
||||||
break
|
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)
|
matched = classify_tool_categories(scan_text)
|
||||||
if not matched:
|
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()
|
seen: set[str] = set()
|
||||||
dynamic: list[str] = []
|
dynamic: list[str] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user