From ba0cb07c91d55d0382c9da38320d9736ec32aa70 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 15 Apr 2026 17:35:30 -0400 Subject: [PATCH] fix(chat): surface silent generations instead of empty bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qwen3:14b sometimes burns output tokens on tool-calling attempts whose emission doesn't parse into any field we read — eval_count > 0 but no thinking/content/tool_calls ever stream to the caller. Generation completes "successfully," the user sees an empty assistant bubble, and no error is logged. Seen in conv 220 today. Two safety rails: - stream_chat_with_tools now tracks whether it yielded anything; when Ollama's done frame reports eval_count > 0 with zero yields, log a warning including the last ~5 raw frames so the next occurrence leaves breadcrumbs for diagnosis. - run_generation checks the same post-condition after the tool loop exits and, if content is empty with no tool calls but output_tokens > 0, substitutes a visible fallback message and streams it as a chunk so the user gets something readable instead of a blank bubble. Co-Authored-By: Claude Opus 4.6 --- .../services/generation_task.py | 23 +++++++++++++++++++ src/fabledassistant/services/llm.py | 21 ++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index dadad0d..d9ab21c 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -542,6 +542,29 @@ async def run_generation( # Strip model artifacts from final content buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far) + # Silent-generation safety net: the model burned output tokens but + # nothing landed in content or tool_calls (seen with qwen3:14b when + # its tool-call emission doesn't parse). Show a visible fallback so + # the user isn't staring at an empty bubble. + if ( + not cancelled + and not buf.content_so_far.strip() + and not all_tool_calls + and (timing.get("output_tokens") or 0) > 0 + ): + logger.warning( + "Silent generation for conv %d: output_tokens=%s but empty content " + "and no tool calls (model=%s)", + conv_id, timing.get("output_tokens"), model, + ) + fallback = ( + "I wasn't able to produce a usable response — the model generated " + "tokens that couldn't be parsed as content or a tool call. " + "Please try rephrasing, or try again." + ) + buf.content_so_far = fallback + buf.append_event("chunk", {"chunk": fallback}) + # Final save logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d", conv_id, len(buf.content_so_far), len(all_tool_calls)) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index ba7ab13..6786618 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -265,20 +265,31 @@ async def stream_chat_with_tools( ) as resp: await _raise_ollama_error(resp, model) accumulated_tool_calls: list[dict] = [] + # Silent-generation diagnostic: if Ollama reports non-zero eval_count + # but we never yielded any thinking/content/tool_calls, something + # in the frames isn't landing in a field we read. Capture the last + # few frames so we can see what Ollama actually sent. + yielded_anything = False + recent_frames: list[str] = [] async for line in resp.aiter_lines(): if not line.strip(): continue + if len(recent_frames) >= 5: + recent_frames.pop(0) + recent_frames.append(line[:500]) data = json.loads(line) msg = data.get("message", {}) # Thinking chunks (qwen3 chain-of-thought, only when think=True) thinking = msg.get("thinking", "") if thinking: + yielded_anything = True yield ChatChunk(type="thinking", content=thinking) # Content chunks chunk = msg.get("content", "") if chunk: + yielded_anything = True yield ChatChunk(type="content", content=chunk) # Collect tool calls from any message (some models @@ -294,13 +305,21 @@ async def stream_chat_with_tools( len(accumulated_tool_calls), json.dumps(accumulated_tool_calls)[:500], ) + yielded_anything = True yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls) else: logger.debug("Ollama done with no tool calls") + eval_count = data.get("eval_count") or 0 + if not yielded_anything and eval_count > 0: + logger.warning( + "Ollama silent generation: model=%s eval_count=%d but no " + "thinking/content/tool_calls were yielded. Last frames: %s", + model, eval_count, recent_frames, + ) yield ChatChunk( type="done", prompt_tokens=data.get("prompt_eval_count"), - output_tokens=data.get("eval_count"), + output_tokens=eval_count, ) break