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:
@@ -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
|
||||
|
||||
@@ -17,6 +17,23 @@ class AgentMode(StrEnum):
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
class ModelProfile(BaseModel):
|
||||
"""Per-model overrides applied when switching models."""
|
||||
|
||||
max_conversation_tokens: int | None = Field(
|
||||
default=None, description="Token budget override for this model's context window"
|
||||
)
|
||||
thinking: bool | None = Field(
|
||||
default=None, description="Override thinking mode for this model"
|
||||
)
|
||||
temperature: float | None = Field(
|
||||
default=None, description="Override sampling temperature"
|
||||
)
|
||||
max_tokens: int | None = Field(
|
||||
default=None, description="Override max response tokens"
|
||||
)
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
"""LLM backend configuration."""
|
||||
|
||||
@@ -145,6 +162,10 @@ class AppConfig(BaseModel):
|
||||
session: SessionConfig = Field(default_factory=SessionConfig)
|
||||
debug: DebugConfig = Field(default_factory=DebugConfig)
|
||||
skills: SkillsConfig = Field(default_factory=SkillsConfig)
|
||||
model_profiles: dict[str, ModelProfile] = Field(
|
||||
default_factory=dict,
|
||||
description="Per-model overrides keyed by model name prefix",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def resolve_workspace_root(self) -> "AppConfig":
|
||||
@@ -152,6 +173,39 @@ class AppConfig(BaseModel):
|
||||
self.agent.workspace_root = self.agent.workspace_root.resolve()
|
||||
return self
|
||||
|
||||
def get_model_profile(self, model: str) -> ModelProfile | None:
|
||||
"""Find the best matching model profile by prefix.
|
||||
|
||||
Matches the longest prefix first (e.g., "llama3.1" beats "llama3"
|
||||
for model "llama3.1:latest"). Returns None if no profile matches.
|
||||
"""
|
||||
model_lower = model.lower().split(":")[0] # strip tag
|
||||
best_match: str | None = None
|
||||
for key in self.model_profiles:
|
||||
key_lower = key.lower()
|
||||
if model_lower == key_lower or model_lower.startswith(key_lower):
|
||||
if best_match is None or len(key) > len(best_match):
|
||||
best_match = key
|
||||
return self.model_profiles.get(best_match) if best_match else None
|
||||
|
||||
def apply_model_profile(self, model: str) -> ModelProfile | None:
|
||||
"""Apply the matching model profile overrides to the active config.
|
||||
|
||||
Returns the applied profile, or None if no profile matched.
|
||||
"""
|
||||
profile = self.get_model_profile(model)
|
||||
if profile is None:
|
||||
return None
|
||||
if profile.max_conversation_tokens is not None:
|
||||
self.agent.max_conversation_tokens = profile.max_conversation_tokens
|
||||
if profile.thinking is not None:
|
||||
self.llm.thinking = profile.thinking
|
||||
if profile.temperature is not None:
|
||||
self.llm.temperature = profile.temperature
|
||||
if profile.max_tokens is not None:
|
||||
self.llm.max_tokens = profile.max_tokens
|
||||
return profile
|
||||
|
||||
|
||||
# Default config file location relative to project root
|
||||
_DEFAULT_CONFIG_PATH = Path("config/config.yaml")
|
||||
|
||||
@@ -151,8 +151,9 @@ class LLMClient:
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
# When thinking is disabled, inject chat_template_kwargs for backends that support it
|
||||
if not self._config.thinking:
|
||||
# When thinking is disabled, inject chat_template_kwargs for backends
|
||||
# that support it (Qwen 3.x thinking models).
|
||||
if not self._config.thinking and self._config.model.lower().startswith(("qwen", "qwq")):
|
||||
payload.setdefault("chat_template_kwargs", {})["enable_thinking"] = False
|
||||
|
||||
# Merge model-specific extra parameters (e.g., reasoning_effort)
|
||||
@@ -170,20 +171,32 @@ class LLMClient:
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
chunk_count = 0
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
data = line[6:] # strip "data: " prefix
|
||||
|
||||
if data.strip() == "[DONE]":
|
||||
return
|
||||
# SSE format: "data: {json}" or "data: [DONE]"
|
||||
if line.startswith("data: "):
|
||||
data = line[6:]
|
||||
if data.strip() == "[DONE]":
|
||||
break
|
||||
elif line.startswith("{"):
|
||||
# Plain NDJSON fallback (some Ollama versions)
|
||||
data = line
|
||||
else:
|
||||
continue
|
||||
|
||||
try:
|
||||
yield json.loads(data)
|
||||
chunk_count += 1
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("malformed_sse_chunk", data=data[:200])
|
||||
|
||||
if chunk_count == 0:
|
||||
logger.warning("empty_stream", model=self._config.model)
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
raise LLMConnectionError(f"Cannot connect to LLM endpoint: {e}") from e
|
||||
except httpx.TimeoutException as e:
|
||||
|
||||
@@ -52,6 +52,10 @@ class SessionManager:
|
||||
self._session_dir = workspace_root / config.session_dir
|
||||
self._session_id = f"{self._workspace_hash}_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
def update_model(self, model: str) -> None:
|
||||
"""Update the model name for session metadata."""
|
||||
self._model = model
|
||||
|
||||
def save(self, ctx: "SessionContext") -> Path:
|
||||
"""Save session state to a JSON file via atomic write.
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ class StreamHandler:
|
||||
"""
|
||||
thinking_notified = False
|
||||
last_update_time = 0.0
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in chunk_iter:
|
||||
chunk_count += 1
|
||||
self._process_chunk(chunk)
|
||||
|
||||
if not self._display_config.stream_output:
|
||||
@@ -96,6 +98,14 @@ class StreamHandler:
|
||||
self._on_done()
|
||||
|
||||
tool_calls = self._build_tool_calls() or None
|
||||
|
||||
if chunk_count > 0 and not self._accumulated_content and not tool_calls:
|
||||
logger.debug(
|
||||
"stream_empty_result",
|
||||
chunks_received=chunk_count,
|
||||
had_reasoning=bool(self._accumulated_reasoning),
|
||||
)
|
||||
|
||||
return Message(
|
||||
role="assistant",
|
||||
content=self._accumulated_content or None,
|
||||
|
||||
@@ -65,11 +65,12 @@ class ReadFileTool(BaseTool):
|
||||
error=str(exc),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
filename = Path(file_path).name
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=self.name,
|
||||
status=ToolResultStatus.ERROR,
|
||||
error=str(exc),
|
||||
error=f"{exc}. Use find_files to locate it, e.g. find_files(pattern=\"{filename}\")",
|
||||
)
|
||||
except FileSizeError as exc:
|
||||
return ToolResult(
|
||||
|
||||
@@ -75,6 +75,9 @@ class SneakyCodeApp(App):
|
||||
"""Initialize agent components after the app is mounted."""
|
||||
setup_logging_for_tui()
|
||||
|
||||
# Apply model profile for the initial model before creating context
|
||||
self._config.apply_model_profile(self._config.llm.model)
|
||||
|
||||
self._ctx = SessionContext(self._config)
|
||||
|
||||
# Create long-lived agent dependencies (reused across turns)
|
||||
@@ -255,8 +258,29 @@ class SneakyCodeApp(App):
|
||||
else:
|
||||
new_model = parts[1].strip()
|
||||
self._config.llm.model = new_model
|
||||
if self._session_mgr:
|
||||
self._session_mgr.update_model(new_model)
|
||||
# Apply model-specific profile overrides
|
||||
profile = self._config.apply_model_profile(new_model)
|
||||
if profile and self._ctx:
|
||||
# Update token budget if the profile overrides it
|
||||
self._ctx.token_counter.budget = self._config.agent.max_conversation_tokens
|
||||
self.query_one(HeaderPanel).update_model(new_model)
|
||||
log.write(Text(f"Switched to model: {new_model}", style="bold green"))
|
||||
header = self.query_one(HeaderPanel)
|
||||
header.update_tokens(
|
||||
self._ctx.estimated_tokens if self._ctx else 0,
|
||||
self._config.agent.max_conversation_tokens,
|
||||
)
|
||||
msg = f"Switched to model: {new_model}"
|
||||
if profile:
|
||||
overrides = []
|
||||
if profile.max_conversation_tokens is not None:
|
||||
overrides.append(f"tokens={profile.max_conversation_tokens:,}")
|
||||
if profile.thinking is not None:
|
||||
overrides.append(f"thinking={'on' if profile.thinking else 'off'}")
|
||||
if overrides:
|
||||
msg += f" ({', '.join(overrides)})"
|
||||
log.write(Text(msg, style="bold green"))
|
||||
elif cmd.split()[0] == "/mode":
|
||||
parts = command.split(maxsplit=1)
|
||||
if len(parts) == 1:
|
||||
|
||||
@@ -36,6 +36,11 @@ class TokenCounter:
|
||||
"""The configured token budget."""
|
||||
return self._budget
|
||||
|
||||
@budget.setter
|
||||
def budget(self, value: int) -> None:
|
||||
"""Update the token budget (e.g., when switching models)."""
|
||||
self._budget = value
|
||||
|
||||
@property
|
||||
def cumulative_usage(self) -> TokenUsage:
|
||||
"""Cumulative token usage across all tracked calls."""
|
||||
|
||||
@@ -18,11 +18,24 @@ llm:
|
||||
|
||||
agent:
|
||||
max_iterations: 25
|
||||
max_conversation_tokens: 32000
|
||||
max_conversation_tokens: 32000 # Default token budget (overridden by model_profiles)
|
||||
workspace_root: "."
|
||||
truncation_keep_recent: 10
|
||||
truncation_threshold: 0.85
|
||||
|
||||
# Per-model overrides — matched by longest model name prefix.
|
||||
# Unset fields fall through to the defaults above.
|
||||
model_profiles:
|
||||
llama3:
|
||||
max_conversation_tokens: 120000
|
||||
thinking: false
|
||||
qwen:
|
||||
max_conversation_tokens: 32000
|
||||
thinking: false
|
||||
qwq:
|
||||
max_conversation_tokens: 32000
|
||||
thinking: true
|
||||
|
||||
permissions:
|
||||
auto_approve:
|
||||
- read_file
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
Pressing up should cycle history like claude code.
|
||||
# UI Issues
|
||||
on /clear we need to reset the token counter in the header panel.
|
||||
|
||||
~~Remove the user's input from output "you" - it's not needed.~~ Brought back as a condensed one-liner (first line + line count for multi-line).
|
||||
# Bugs
|
||||
|
||||
Smart shell auto-approve: auto-approve `run_command` when the base executable is in the `allowed_commands` list and the full command doesn't match any `denied_commands` prefix. Only prompt the user for commands whose base executable is unlisted. Currently all shell commands prompt regardless, which is tedious for safe read-only commands like `git branch` or `ls`. The allow/deny lists in `ShellToolConfig` already define what's safe — the permissions service just needs to be shell-aware.
|
||||
# Improvements
|
||||
add -p to command line args so that the agent can run the prompt and return data directly via STDOUT
|
||||
|
||||
Show a token count or some other display for when the model is "thinking" for a long period of time. I want a way for the user to know the model is working on it.
|
||||
# Open questions:
|
||||
How might we pass a directory to this app and have it use that directory as it's workspace so I don't have to copy files or do odd things to work in other directories.
|
||||
|
||||
/models command to show models available and temporarly change models in the session
|
||||
|
||||
pass a directory to the tool so that it uses that directory as it's root for commands.
|
||||
|
||||
add a skills directory so we can prompt our own skills for the tool to use similar to Claude Code
|
||||
|
||||
need not only a session log, but also a log of what the llm is thinking and how it's working somehow. I need a way to see behind the curtain.
|
||||
|
||||
# Left of from Phase 7 of old roadmap - finish these first
|
||||
- Permission modal auto-approves (TODO: proper modal dialog)
|
||||
- Session resume auto-resumes (TODO: modal y/n)
|
||||
- LLM client cleanup on unmount not yet wired
|
||||
- No automated TUI tests (Textual's AppTest can be added later)
|
||||
How do we handle huge files not taking up so many tokens?
|
||||
Reference in New Issue
Block a user