Fix research_topic loop when intent misses and main model calls tool directly

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 13:05:36 -05:00
parent df4c52412d
commit a95d17fc04
@@ -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