fix: empty response handling, /no_think model gating, per-model profiles

- Detect empty LLM responses (no content, no tool calls) instead of
  silently treating them as task completion. Retries once without tools
  before warning the user.
- Gate /no_think system message and chat_template_kwargs to Qwen/QwQ
  models only — sending /no_think to llama3.x caused empty responses.
- Add model_profiles config section for per-model overrides (token
  budget, thinking, temperature, max_tokens) matched by name prefix.
  Applied at startup and on /model switch.
- Update SessionManager on /model switch so session files record the
  correct model.
- Add NDJSON fallback in SSE stream parser for Ollama compatibility.
- Improve read_file error to suggest find_files on FileNotFoundError.
- Add diagnostic logging for empty streams and empty results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:09:04 -05:00
parent 1ee721ac10
commit 16d79df421
10 changed files with 191 additions and 33 deletions

View File

@@ -105,17 +105,25 @@ class AgentLoop:
)
return prompt
# Models whose chat templates understand /no_think directives.
_THINKING_MODEL_PREFIXES = ("qwen", "qwq")
def _model_supports_no_think(self) -> bool:
"""Check if the current model uses a thinking chat template."""
model_lower = self._config.llm.model.lower()
return any(model_lower.startswith(p) for p in self._THINKING_MODEL_PREFIXES)
def _get_messages_with_system_prompt(self) -> list[Message]:
"""Prepend the system prompt to conversation history.
When thinking is disabled, appends a system-level /no_think directive
after the last user message so Qwen 3.x (and similar) chat templates
see it, without polluting the user's actual message content.
When thinking is disabled on a model that supports it, appends a
system-level /no_think directive after the last user message so
Qwen 3.x (and similar) chat templates see it.
"""
system_msg = Message(role="system", content=self._system_prompt)
history = self._ctx.get_history()
if not self._config.llm.thinking and history:
if not self._config.llm.thinking and self._model_supports_no_think() and history:
history = list(history)
# Find last user message and insert a system hint after it
for i in range(len(history) - 1, -1, -1):
@@ -140,6 +148,7 @@ class AgentLoop:
max_iter = self._config.agent.max_iterations
reasoning_only_streak = 0
empty_streak = 0
for iteration in range(1, max_iter + 1):
if self._cancelled:
if self._display:
@@ -230,6 +239,36 @@ class AgentLoop:
# Successful response — reset streak
reasoning_only_streak = 0
# Detect completely empty response (no content, no tool calls)
if not assistant_msg.content and not assistant_msg.tool_calls:
empty_streak += 1
self._ctx.pop_last_message() # Don't keep empty messages
if empty_streak >= 2:
if self._display:
self._display.write_warning(
"Model returned repeated empty responses — "
"try a different model or check Ollama logs."
)
break
if self._display:
self._display.write_warning("Model returned empty response. Retrying without tools...")
# Retry without tool schemas — some models return empty when
# tools are in the payload but the model can't handle them.
assistant_msg = await self._llm_step(skip_tools=True)
if assistant_msg is None:
break
if assistant_msg.content:
self._ctx.add_message("assistant", assistant_msg.content)
if self._display:
self._display.write_assistant_message(assistant_msg.content)
self._handler.reset()
break
# Still empty even without tools
self._handler.reset()
continue
empty_streak = 0 # reset on successful non-empty response
# Display any assistant text content (even if tool calls follow)
if self._display and assistant_msg.content:
self._display.write_assistant_message(assistant_msg.content)
@@ -263,21 +302,25 @@ class AgentLoop:
if self._display:
self._display.write_warning(f"Agent reached maximum iterations ({max_iter}). Stopping.")
async def _llm_step(self) -> Message | None:
async def _llm_step(self, *, skip_tools: bool = False) -> Message | None:
"""Stream one LLM response and return the accumulated Message.
Uses retry-enabled streaming. On mid-stream errors, attempts to recover
partial content if available.
Args:
skip_tools: If True, send the request without tool schemas (fallback mode).
Returns:
The assistant Message, or None if an error occurred.
"""
messages = self._get_messages_with_system_prompt()
if self._debug:
self._debug.log_request(messages, self._config.llm.model)
tools = None if skip_tools else self._tools_schema
t0 = time.monotonic()
try:
chunk_iter = self._client.stream_chat_with_retry(messages, tools=self._tools_schema)
chunk_iter = self._client.stream_chat_with_retry(messages, tools=tools)
result = await self._handler.process_stream(chunk_iter)
if result and self._debug:
elapsed = (time.monotonic() - t0) * 1000