From a95d17fc048679d1fb12e790ade0ed4f7d3e1375 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Feb 2026 13:05:36 -0500 Subject: [PATCH] Fix research_topic loop when intent misses and main model calls tool directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the intent model doesn't classify a research request (low confidence, long message, etc.), the main model (qwen3) would correctly identify research_topic itself and call it via the streaming tool loop. But execute_tool("research_topic") only returns a dummy research_pending placeholder, causing the model to see the result and retry — looping up to MAX_TOOL_ROUNDS times. Fix: filter research_topic out of stream_tools (the tool list given to the main model via stream_chat_with_tools). research_topic is an intent-only routing tool; the main model should never call it directly. The full tools list (including research_topic) is still passed to classify_intent so intent routing continues to work. The _INTENT_ONLY_TOOLS frozenset makes this pattern explicit and extensible for future intent-only tools. Co-Authored-By: Claude Sonnet 4.6 --- .../services/generation_task.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 7a6fca0..2f727cd 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -237,9 +237,20 @@ async def run_generation( get_setting(user_id, "intent_model", ""), ) intent_model = intent_model_setting or Config.OLLAMA_INTENT_MODEL or model + + # research_topic is handled exclusively by the intent-first pipeline (round 0). + # Remove it from the tool list given to the main model so it can never be called + # directly via the streaming tool loop (which would just return a placeholder result + # and cause the model to retry in a loop). + _INTENT_ONLY_TOOLS = frozenset({"research_topic"}) + stream_tools = [ + t for t in tools + if t.get("function", {}).get("name") not in _INTENT_ONLY_TOOLS + ] + logger.info( - "Starting generation for conv %d: model=%s, intent_model=%s, tools=%d", - conv_id, model, intent_model, len(tools), + "Starting generation for conv %d: model=%s, intent_model=%s, tools=%d (stream_tools=%d)", + conv_id, model, intent_model, len(tools), len(stream_tools), ) # Phase 2: Summarize long conversation history if needed. @@ -443,7 +454,7 @@ async def run_generation( buf.append_event("status", {"status": "Generating response..."}) t_stream = time.monotonic() - async for chunk in _stream_with_retry(messages, model, tools, think): + async for chunk in _stream_with_retry(messages, model, stream_tools, think): if buf.cancel_event.is_set(): cancelled = True break @@ -515,7 +526,7 @@ async def run_generation( buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."}) t_stream = time.monotonic() - async for chunk in _stream_with_retry(messages, model, tools, think): + async for chunk in _stream_with_retry(messages, model, stream_tools, think): if buf.cancel_event.is_set(): cancelled = True break