From 06378621c2690be1a76ccd49092bcaec00d58942 Mon Sep 17 00:00:00 2001 From: holden093 Date: Mon, 15 Jun 2026 21:11:41 +0200 Subject: [PATCH 1/7] feat(agent): pluggable LLM domain classifier for multi-language tool retrieval Add an async _llm_classify_domains() function that calls the utility model to classify user queries into domain categories. Gated by the environment variable ODYSSEUS_DOMAIN_CLASSIFIER=llm. When the env var is set, the English regex classifier is skipped entirely and every query is routed through the LLM for language-agnostic classification. Without the env var the existing regex path runs unchanged (the LLM is available as a fallback when no regex domains match). The LLM call uses the configured utility model. A capable instruction- following model (e.g. deepseek-v4-flash) is required for reliable results; smaller local models may not follow the constrained output format. The feature is opt-in so operators can enable it only when a suitable utility model is available. Closes: #3713, #3766 (non-English domain classifier) --- src/agent_loop.py | 109 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index 592ebaec1..09f533346 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -9,6 +9,7 @@ import asyncio import collections import json +import os import re import time import logging @@ -1994,6 +1995,70 @@ def repl(match: re.Match) -> str: ) +async def _llm_classify_domains(query: str, owner: Optional[str] = None) -> Set[str]: + """Use the utility model to classify a query into domains. + + Called when ODYSSEUS_DOMAIN_CLASSIFIER=llm or as a fallback when the + regex-based classifier found no domains for a non-continuation query. + """ + from src.endpoint_resolver import resolve_endpoint + from src.llm_core import llm_call_async + + ep_url, ep_model, headers = resolve_endpoint("utility", owner=owner) + if not ep_url or not ep_model: + return set() + + valid_domains = sorted(_DOMAIN_TOOL_MAP.keys()) + prompt = ( + "Output ONLY a comma-separated list from: " + + ", ".join(valid_domains) + ".\n" + "If none match, output exactly: none\n" + "Do NOT translate, explain, or add any other text.\n\n" + f"Message: \"{query[:500]}\"\n" + "Categories:" + ) + + _DOMAIN_HINTS = ( + "Classify messages into domain categories. " + "web = search, weather, news, lookups. " + "contacts = phone, address book. " + "email = mail, inbox, send. " + "documents = writing, editing. " + "notes_calendar_tasks = reminders, events, todos. " + "cookbook = models, serving, GPU. " + "files = directories, code, git, shell. " + "ui = panels, settings, theme. " + "sessions = chat history. " + "settings = configuration. " + "Output ONLY the categories or none." + ) + + try: + raw = await llm_call_async( + url=ep_url, model=ep_model, + messages=[ + {"role": "system", "content": _DOMAIN_HINTS}, + {"role": "user", "content": prompt}, + ], + headers=headers, temperature=0.0, max_tokens=200, timeout=10, + ) + except Exception as e: + logger.warning(f"[llm-domain] Classification call failed: {e}") + return set() + + raw = (raw or "").strip().lower() + if raw == "none" or not raw: + return set() + + result: Set[str] = set() + for part in raw.split(","): + part = part.strip() + if part in _DOMAIN_TOOL_MAP: + result.add(part) + logger.info(f"[llm-domain] Classified {query[:80]!r} -> {sorted(result)}") + return result + + def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_chars: int = 600) -> str: """Build the tool-retrieval query from the last few USER turns, not just the latest one. @@ -3151,6 +3216,11 @@ async def stream_agent_loop( except (TypeError, ValueError): temperature = 0.2 _ody_memory_identity_turn = _looks_like_memory_identity_turn(_last_user) + + # Domain classifier selection: ODYSSEUS_DOMAIN_CLASSIFIER=llm uses the + # utility model for language-agnostic classification. Otherwise the + # English regex classifier runs (with optional LLM fallback on miss). + _use_llm_classifier = os.getenv("ODYSSEUS_DOMAIN_CLASSIFIER", "") == "llm" _intent = _classify_agent_request(messages, _last_user) _low_signal_turn = bool(_intent.get("low_signal")) _casual_low_signal_turn = _is_casual_low_signal(_last_user) @@ -3163,6 +3233,33 @@ async def stream_agent_loop( "mcp__email__list_emails", "mcp__email__read_email", "mcp__email__scan_email_unsubscribes", }) _prompt_active_document = active_document if _active_document_relevant else None + if _use_llm_classifier and not _intent.get("continuation") and _last_user: + # Skip regex results entirely — the LLM owns classification. + try: + _llm_domains = await asyncio.wait_for( + _llm_classify_domains(_last_user, owner=owner), + timeout=5, + ) + except (asyncio.TimeoutError, Exception): + _llm_domains = set() + _intent["domains"] = _llm_domains + _intent["low_signal"] = not bool(_llm_domains) + elif not _intent.get("domains") and not _intent.get("continuation") and _last_user: + # Regex found nothing — try LLM as a fallback. + try: + _llm_domains = await asyncio.wait_for( + _llm_classify_domains(_last_user, owner=owner), + timeout=5, + ) + except (asyncio.TimeoutError, Exception): + _llm_domains = set() + if _llm_domains: + _intent["domains"] = _llm_domains + _intent["low_signal"] = False + logger.info( + "[agent-intent] LLM fallback added domains: %s", + sorted(_llm_domains), + ) _direct_low_signal = ( _low_signal_turn and not _existing_conversation @@ -3203,7 +3300,7 @@ async def stream_agent_loop( "[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s active_doc_relevant=%s retrieval_query=%r", _last_user[:120], bool(_intent.get("continuation")), - _low_signal_turn, + bool(_intent.get("low_signal")), sorted(_intent.get("domains") or []), _active_document_relevant, _retrieval_query[:200], @@ -3214,6 +3311,7 @@ async def stream_agent_loop( _last_user[:80], ) _mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {} +<<<<<<< HEAD if _direct_low_signal: logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80]) direct_messages = ( @@ -3297,6 +3395,7 @@ async def stream_agent_loop( yield "data: [DONE]\n\n" return +======= if plan_mode and mcp_mgr: # Allow read-only MCP tools to investigate, block write/unknown ones: # hide them from the schemas AND reject them at runtime by qualified name. @@ -3308,11 +3407,11 @@ async def stream_agent_loop( # RAG-based tool selection: retrieve relevant tools for this query. # If caller provided a pre-computed set (e.g. task_scheduler), use that. - _relevant_tools = relevant_tools + _relevant_tools = set() if guide_only else relevant_tools _t1 = time.time() if _relevant_tools: logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)") - if not guide_only and not _relevant_tools and _low_signal_turn: + if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")): from src.tool_index import ALWAYS_AVAILABLE if workspace: # An active workspace IS the file-work signal: a vague "look at the @@ -3482,7 +3581,7 @@ async def stream_agent_loop( # (grep, read_file, ...) that aren't in its schema list. Keep the schemas # in lockstep: manage_skills is callable whenever any skill is indexed, # and a matched skill's declared requires_toolsets ride along with it. - if not guide_only and _relevant_tools is not None and not _low_signal_turn: + if not guide_only and _relevant_tools is not None and not bool(_intent.get("low_signal")): try: from services.memory.skills import SkillsManager from src.constants import DATA_DIR @@ -3678,7 +3777,7 @@ async def stream_agent_loop( compact=_compact_agent_prompt, owner=owner, suppress_local_context=guide_only, - suppress_skills=_low_signal_turn, + suppress_skills=bool(_intent.get("low_signal")), active_email=active_email, workspace=workspace, ) From 97df8f4701f1a9879a8254648cadbc8129e281f2 Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 20 Jun 2026 10:58:23 +0200 Subject: [PATCH 2/7] docs(env): document ODYSSEUS_DOMAIN_CLASSIFIER in .env.example --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index d23276eb8..f72e3c323 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,14 @@ LLM_HOST=localhost # Research service LLM endpoint # RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions +# Pluggable LLM domain classifier for multi-language tool retrieval. +# Set to "llm" to route every user query through the utility model for +# language-agnostic domain classification. Requires a capable instruction- +# following utility model (e.g. deepseek-v4-flash); smaller local models +# may not follow the constrained output format. When unset or set to +# anything else, the English regex classifier runs as normal. +# ODYSSEUS_DOMAIN_CLASSIFIER=llm + # Extra CA bundle for LLM providers whose TLS chain isn't in the default # trust store. Layered ON TOP of the system / certifi bundle — verification # stays on for every host, the trust set just gets larger. Useful for: From c656a4ff02d5f39a31d18239e58db1786decc2e0 Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 20 Jun 2026 11:13:30 +0200 Subject: [PATCH 3/7] fix(agent): feed conversation context to LLM domain classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always pass _recent_context_for_retrieval() to _llm_classify_domains() so multi-language follow-ups like "Fai tutto tu", "Sì", "Fallo" inherit domain from prior user turns instead of losing tool context. Removes the continuation guard in LLM mode — the LLM now runs every turn with full context, making regex language limitations irrelevant. Also updates the classifier prompt to label multi-turn context as "Recent conversation" and adds a "Classify the latest request" instruction for clarity. Closes: #4355 domain-context-loss on non-English follow-ups --- src/agent_loop.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index 09f533346..2aac22d89 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -2014,7 +2014,8 @@ async def _llm_classify_domains(query: str, owner: Optional[str] = None) -> Set[ + ", ".join(valid_domains) + ".\n" "If none match, output exactly: none\n" "Do NOT translate, explain, or add any other text.\n\n" - f"Message: \"{query[:500]}\"\n" + f"Recent conversation:\n\"{query[:500]}\"\n\n" + "Classify the latest request.\n" "Categories:" ) @@ -3233,22 +3234,27 @@ async def stream_agent_loop( "mcp__email__list_emails", "mcp__email__read_email", "mcp__email__scan_email_unsubscribes", }) _prompt_active_document = active_document if _active_document_relevant else None - if _use_llm_classifier and not _intent.get("continuation") and _last_user: - # Skip regex results entirely — the LLM owns classification. + if _use_llm_classifier and _last_user: + # LLM owns classification — always feed it conversation context + # so multi-language follow-ups ("Fai tutto tu", "Sì", "Fallo") + # inherit domain from prior turns. + _llm_query = _recent_context_for_retrieval(messages) try: _llm_domains = await asyncio.wait_for( - _llm_classify_domains(_last_user, owner=owner), + _llm_classify_domains(_llm_query, owner=owner), timeout=5, ) except (asyncio.TimeoutError, Exception): _llm_domains = set() _intent["domains"] = _llm_domains _intent["low_signal"] = not bool(_llm_domains) + _intent["retrieval_query"] = _llm_query elif not _intent.get("domains") and not _intent.get("continuation") and _last_user: - # Regex found nothing — try LLM as a fallback. + # Regex found nothing — try LLM as a fallback with context. + _llm_query = _recent_context_for_retrieval(messages) try: _llm_domains = await asyncio.wait_for( - _llm_classify_domains(_last_user, owner=owner), + _llm_classify_domains(_llm_query, owner=owner), timeout=5, ) except (asyncio.TimeoutError, Exception): @@ -3256,6 +3262,7 @@ async def stream_agent_loop( if _llm_domains: _intent["domains"] = _llm_domains _intent["low_signal"] = False + _intent["retrieval_query"] = _llm_query logger.info( "[agent-intent] LLM fallback added domains: %s", sorted(_llm_domains), From a347736c56e36dc11f2b816bf9712b0b462f280e Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 20 Jun 2026 13:25:36 +0200 Subject: [PATCH 4/7] fix(agent): separate latest request from history in LLM classifier prompt Split the prompt into "Conversation history" and "Latest request" sections when latest_msg is provided. Prevents short keywords like "internet" from being drowned by domain-heavy conversation context when the user asks a cross-domain question (e.g. "search the web for this" while in a contacts-heavy conversation). --- src/agent_loop.py | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index 2aac22d89..04ec723f7 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -1995,11 +1995,16 @@ def repl(match: re.Match) -> str: ) -async def _llm_classify_domains(query: str, owner: Optional[str] = None) -> Set[str]: +async def _llm_classify_domains(query: str, owner: Optional[str] = None, latest_msg: Optional[str] = None) -> Set[str]: """Use the utility model to classify a query into domains. Called when ODYSSEUS_DOMAIN_CLASSIFIER=llm or as a fallback when the regex-based classifier found no domains for a non-continuation query. + + When latest_msg is provided, it is separated from the conversation + history so the LLM can weigh the current request against prior context + — prevents short keywords like "internet" from being drowned by + domain-heavy history. """ from src.endpoint_resolver import resolve_endpoint from src.llm_core import llm_call_async @@ -2009,15 +2014,28 @@ async def _llm_classify_domains(query: str, owner: Optional[str] = None) -> Set[ return set() valid_domains = sorted(_DOMAIN_TOOL_MAP.keys()) - prompt = ( - "Output ONLY a comma-separated list from: " - + ", ".join(valid_domains) + ".\n" - "If none match, output exactly: none\n" - "Do NOT translate, explain, or add any other text.\n\n" - f"Recent conversation:\n\"{query[:500]}\"\n\n" - "Classify the latest request.\n" - "Categories:" - ) + if latest_msg and latest_msg != query: + history = query[:400] if len(query) > 400 else query + prompt = ( + "Output ONLY a comma-separated list from: " + + ", ".join(valid_domains) + ".\n" + "If none match, output exactly: none\n" + "Do NOT translate, explain, or add any other text.\n\n" + f"Conversation history:\n\"{history}\"\n\n" + f"Latest request:\n\"{latest_msg[:200]}\"\n\n" + "Classify the latest request.\n" + "Categories:" + ) + else: + prompt = ( + "Output ONLY a comma-separated list from: " + + ", ".join(valid_domains) + ".\n" + "If none match, output exactly: none\n" + "Do NOT translate, explain, or add any other text.\n\n" + f"Recent conversation:\n\"{query[:500]}\"\n\n" + "Classify the latest request.\n" + "Categories:" + ) _DOMAIN_HINTS = ( "Classify messages into domain categories. " @@ -3241,7 +3259,7 @@ async def stream_agent_loop( _llm_query = _recent_context_for_retrieval(messages) try: _llm_domains = await asyncio.wait_for( - _llm_classify_domains(_llm_query, owner=owner), + _llm_classify_domains(_llm_query, owner=owner, latest_msg=_last_user), timeout=5, ) except (asyncio.TimeoutError, Exception): @@ -3254,7 +3272,7 @@ async def stream_agent_loop( _llm_query = _recent_context_for_retrieval(messages) try: _llm_domains = await asyncio.wait_for( - _llm_classify_domains(_llm_query, owner=owner), + _llm_classify_domains(_llm_query, owner=owner, latest_msg=_last_user), timeout=5, ) except (asyncio.TimeoutError, Exception): From 163c64cb9e6a333cb463881728fb77ebcdf702b0 Mon Sep 17 00:00:00 2001 From: holden093 Date: Mon, 29 Jun 2026 14:38:35 +0200 Subject: [PATCH 5/7] fix(compose): add missing ODYSSEUS_DOMAIN_CLASSIFIER env to all compose files --- docker-compose.gpu-amd.yml | 1 + docker-compose.gpu-nvidia.yml | 1 + docker-compose.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 91e223e05..ddeec27a1 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -67,6 +67,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_DOMAIN_CLASSIFIER=${ODYSSEUS_DOMAIN_CLASSIFIER:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index e8c2fd032..77d423620 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -66,6 +66,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_DOMAIN_CLASSIFIER=${ODYSSEUS_DOMAIN_CLASSIFIER:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.yml b/docker-compose.yml index b1f2c37ee..6b2f8bd56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_DOMAIN_CLASSIFIER=${ODYSSEUS_DOMAIN_CLASSIFIER:-} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} From cfa19e01f8ec621dff807d6cb714f46c3fe4648e Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 25 Jul 2026 18:43:03 +0200 Subject: [PATCH 6/7] fix(agent): remove stray conflict markers left in agent_loop.py Commit 5019845 (the original domain-classifier feature commit) committed an unresolved conflict (<<<<<<< HEAD / ======= with no closing marker) into src/agent_loop.py, breaking 'python -m compileall'. Both sides are valid code; resolution is simply dropping the two stray marker lines, matching the author's own fix on local-dev. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GDcZuLNAo4FVc8WtMpZWVp --- src/agent_loop.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index 04ec723f7..dd97400d6 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -3336,7 +3336,6 @@ async def stream_agent_loop( _last_user[:80], ) _mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {} -<<<<<<< HEAD if _direct_low_signal: logger.info("[agent] direct low-signal reply path for latest=%r", _last_user[:80]) direct_messages = ( @@ -3420,7 +3419,6 @@ async def stream_agent_loop( yield "data: [DONE]\n\n" return -======= if plan_mode and mcp_mgr: # Allow read-only MCP tools to investigate, block write/unknown ones: # hide them from the schemas AND reject them at runtime by qualified name. From bc7235aa8efd84d23c10e494357da2047a9f04b0 Mon Sep 17 00:00:00 2001 From: holden093 Date: Sat, 25 Jul 2026 18:45:54 +0200 Subject: [PATCH 7/7] fix(agent): import Any in agent_loop.py to fix repo-wide test collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev's src/agent_loop.py annotates with dict[str, Any] at lines 1503/1877 but only imports AsyncGenerator, List, Dict, Optional, Set (no Any, no from __future__ import annotations), so importing the module raises NameError: name 'Any' is not defined — breaking pytest collection for every test that transitively imports agent_loop. Add Any to the typing import. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GDcZuLNAo4FVc8WtMpZWVp --- src/agent_loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_loop.py b/src/agent_loop.py index dd97400d6..b5d4c5e1a 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -13,7 +13,7 @@ import re import time import logging -from typing import AsyncGenerator, List, Dict, Optional, Set +from typing import Any, AsyncGenerator, List, Dict, Optional, Set from urllib.parse import urlparse from src.llm_core import (