diff --git a/cortex/openai_orchestrator.py b/cortex/openai_orchestrator.py index 11864c7..1821780 100644 --- a/cortex/openai_orchestrator.py +++ b/cortex/openai_orchestrator.py @@ -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", ) diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 037386a..7c70e9f 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -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] = []