Compare commits

..

10 Commits

Author SHA1 Message Date
6145a23296 added border to input box 2026-03-12 08:59:53 -05:00
16d79df421 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>
2026-03-11 23:09:04 -05:00
1ee721ac10 Merge branch 'bugfix/model-command-regex' 2026-03-11 22:31:50 -05:00
d54a3480b8 fix: /model command no longer incorrectly matched as /mode
Use exact first-word matching instead of startswith() for /models and
/mode command routing. Accept /model as an alias for /models.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:31:45 -05:00
d3b286ba40 Merge branch 'feature/file-cache-lru' 2026-03-11 22:26:56 -05:00
d829e6553c feat: add tool-level file cache with LRU eviction and mtime invalidation
Introduces FileCache (OrderedDict LRU, st_mtime_ns validation) to avoid
redundant disk reads and duplicate content in conversation context.
Read tools return a short "[Cached]" message on cache hit instead of
resending unchanged file content, saving tokens. Write/edit/delete tools
invalidate affected paths; str_replace pre-warms the cache after edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:26:51 -05:00
2c532adbbc Merge branch 'feature/read-many-files-and-fixes' 2026-03-11 21:57:39 -05:00
be1ea81102 fix: preserve streaming UI callbacks across agent loop iterations
StreamHandler.reset() was clearing on_content, on_thinking, and on_done
callbacks after every LLM response, but they were only set once per turn.
This caused the thinking indicator and streaming display to stop working
after the first tool call in a multi-step agent turn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:56:59 -05:00
3fe0f7af47 style: change header background from orange to dark cyan
Improves readability of the lightning bolt emoji and gives the header
a distinctive look.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:56:57 -05:00
05754fe06b feat: add read_many_files tool for batch file reading
Reduces LLM round-trips by allowing multiple files to be read in a
single tool call. Uses best-effort error handling so partial failures
still return successful reads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:56:53 -05:00
18 changed files with 950 additions and 54 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

View File

@@ -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."""
@@ -73,11 +90,19 @@ class ShellToolConfig(BaseModel):
max_output_bytes: int = Field(default=65536, description="Max output capture size in bytes")
class FileCacheConfig(BaseModel):
"""File cache configuration."""
enabled: bool = Field(default=True, description="Enable file content caching")
max_entries: int = Field(default=128, description="Maximum cached file entries (LRU eviction)")
class FilesystemToolConfig(BaseModel):
"""Filesystem tool limits."""
max_file_size_bytes: int = Field(default=1_048_576, description="Max file size for read/write")
binary_detection: bool = Field(default=True, description="Detect and reject binary files")
cache: FileCacheConfig = Field(default_factory=FileCacheConfig, description="File cache settings")
class ToolsConfig(BaseModel):
@@ -137,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":
@@ -144,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")

View File

@@ -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:

View File

@@ -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.

View File

@@ -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,
@@ -183,11 +193,8 @@ class StreamHandler:
return bool(self._accumulated_reasoning) and not self._accumulated_content and not self._tool_calls
def reset(self) -> None:
"""Clear all accumulators for the next turn."""
"""Clear accumulators for the next LLM call, preserving UI callbacks."""
self._accumulated_content = ""
self._accumulated_reasoning = ""
self._tool_calls.clear()
self._usage = None
self._on_content = None
self._on_thinking = None
self._on_done = None

View File

@@ -1,13 +1,17 @@
"""Edit tools: str_replace and patch_apply."""
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from app.models.config import AppConfig
from app.models.tool_call import ToolResult, ToolResultStatus
from app.tools.base import BaseTool
from app.utils.file_cache import FileCache, cached_read_file
from app.utils.file_helpers import (
FileSizeError,
PathSecurityError,
@@ -37,6 +41,12 @@ class StrReplaceTool(BaseTool):
)
params_model = StrReplaceParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(
self, *, tool_call_id: str, file_path: str, old_str: str, new_str: str, **kwargs: Any
) -> ToolResult:
@@ -44,11 +54,12 @@ class StrReplaceTool(BaseTool):
# Read the file
try:
content = safe_read_file(
content = cached_read_file(
file_path,
self.workspace_root,
max_size_bytes=fs_config.max_file_size_bytes,
check_binary=fs_config.binary_detection,
cache=self._file_cache,
)
except PathSecurityError as exc:
return ToolResult(
@@ -117,8 +128,14 @@ class StrReplaceTool(BaseTool):
safe_path = resolve_safe_path(file_path, self.workspace_root)
rel_path = safe_path.relative_to(self.workspace_root)
except (PathSecurityError, ValueError):
safe_path = None
rel_path = Path(file_path)
# Pre-warm cache with the new content (we already have it in memory).
if self._file_cache is not None and safe_path is not None:
self._file_cache.invalidate(safe_path)
self._file_cache.put(safe_path, new_content)
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
@@ -144,6 +161,12 @@ class PatchApplyTool(BaseTool):
)
params_model = PatchApplyParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(self, *, tool_call_id: str, file_path: str, patch: str, **kwargs: Any) -> ToolResult:
try:
safe_path = resolve_safe_path(file_path, self.workspace_root)
@@ -195,6 +218,9 @@ class PatchApplyTool(BaseTool):
error=f"Patch failed (exit {result.returncode}): {result.stderr or result.stdout}",
)
if self._file_cache is not None:
self._file_cache.invalidate(safe_path)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:

View File

@@ -1,12 +1,16 @@
"""Filesystem tools: read_file, list_dir, write_file, make_dir, delete_file."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from app.models.config import AppConfig
from app.models.tool_call import ToolResult, ToolResultStatus
from app.tools.base import BaseTool
from app.utils.file_cache import FileCache, cached_read_file
from app.utils.file_helpers import (
BinaryFileError,
FileSizeError,
@@ -23,6 +27,12 @@ class ReadFileParams(BaseModel):
file_path: str = Field(description="Path to the file to read (relative to workspace root)")
class ReadManyFilesParams(BaseModel):
"""Parameters for the read_many_files tool."""
file_paths: list[str] = Field(description="List of file paths to read (relative to workspace root)")
class ReadFileTool(BaseTool):
"""Read the contents of a file within the workspace."""
@@ -30,14 +40,22 @@ class ReadFileTool(BaseTool):
description = "Read the full contents of a text file. Returns the file content as a string."
params_model = ReadFileParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
fs_config = self.config.tools.filesystem
hits_before = self._file_cache.stats.hits if self._file_cache else 0
try:
content = safe_read_file(
content = cached_read_file(
file_path,
self.workspace_root,
max_size_bytes=fs_config.max_file_size_bytes,
check_binary=fs_config.binary_detection,
cache=self._file_cache,
)
except PathSecurityError as exc:
return ToolResult(
@@ -47,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(
@@ -68,6 +87,23 @@ class ReadFileTool(BaseTool):
error=str(exc),
)
# On cache hit the file is unchanged — its content is already in
# conversation context from the earlier read, so avoid resending it.
was_cache_hit = (
self._file_cache is not None
and self._file_cache.stats.hits > hits_before
)
if was_cache_hit:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output=(
f"[Cached] {file_path} is unchanged since last read "
f"({len(content):,} chars). Content is already in conversation context."
),
)
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
@@ -76,6 +112,76 @@ class ReadFileTool(BaseTool):
)
class ReadManyFilesTool(BaseTool):
"""Read contents of multiple files at once."""
name = "read_many_files"
description = (
"Read contents of multiple files at once. Returns each file's content "
"prefixed with its path header."
)
params_model = ReadManyFilesParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(self, *, tool_call_id: str, file_paths: list[str], **kwargs: Any) -> ToolResult:
if not file_paths:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error="file_paths list is empty",
)
fs_config = self.config.tools.filesystem
sections: list[str] = []
success_count = 0
for fp in file_paths:
hits_before = self._file_cache.stats.hits if self._file_cache else 0
try:
content = cached_read_file(
fp,
self.workspace_root,
max_size_bytes=fs_config.max_file_size_bytes,
check_binary=fs_config.binary_detection,
cache=self._file_cache,
)
was_hit = (
self._file_cache is not None
and self._file_cache.stats.hits > hits_before
)
if was_hit:
sections.append(
f"=== {fp} ===\n[Cached] Unchanged since last read "
f"({len(content):,} chars). Already in conversation context."
)
else:
sections.append(f"=== {fp} ===\n{content}")
success_count += 1
except (PathSecurityError, FileNotFoundError, FileSizeError, BinaryFileError) as exc:
sections.append(f"=== {fp} ===\n[ERROR] {exc}")
if success_count == 0:
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.ERROR,
error="All files failed to read:\n" + "\n".join(sections),
)
return ToolResult(
tool_call_id=tool_call_id,
tool_name=self.name,
status=ToolResultStatus.SUCCESS,
output="\n".join(sections),
)
class ListDirParams(BaseModel):
"""Parameters for the list_dir tool."""
@@ -167,6 +273,12 @@ class WriteFileTool(BaseTool):
)
params_model = WriteFileParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(self, *, tool_call_id: str, file_path: str, content: str, **kwargs: Any) -> ToolResult:
fs_config = self.config.tools.filesystem
try:
@@ -191,6 +303,9 @@ class WriteFileTool(BaseTool):
error=str(exc),
)
if self._file_cache is not None:
self._file_cache.invalidate(safe_path)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:
@@ -272,6 +387,12 @@ class DeleteFileTool(BaseTool):
description = "Delete a single file. Does not delete directories."
params_model = DeleteFileParams
def __init__(
self, workspace_root: Path, config: AppConfig, file_cache: FileCache | None = None
) -> None:
super().__init__(workspace_root, config)
self._file_cache = file_cache
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
try:
safe_path = resolve_safe_path(file_path, self.workspace_root)
@@ -309,6 +430,9 @@ class DeleteFileTool(BaseTool):
error=f"Failed to delete file: {exc}",
)
if self._file_cache is not None:
self._file_cache.invalidate(safe_path)
try:
rel_path = safe_path.relative_to(self.workspace_root)
except ValueError:

View File

@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from app.models.config import AppConfig
from app.tools.base import BaseTool
from app.utils.file_cache import FileCache
if TYPE_CHECKING:
from app.services.skills import SkillsManager
@@ -89,6 +90,7 @@ def create_default_registry(
config: AppConfig,
skills_manager: SkillsManager | None = None,
skill_runner: object | None = None,
file_cache: FileCache | None = None,
) -> ToolRegistry:
"""Create a ToolRegistry populated with all built-in tools.
@@ -97,9 +99,10 @@ def create_default_registry(
config: Application configuration.
skills_manager: Optional skills manager for skill tools.
skill_runner: Optional SkillRunner for package skill activation.
file_cache: Optional file cache shared across file-reading tools.
"""
# Read tools
from app.tools.filesystem import ListDirTool, ReadFileTool
from app.tools.filesystem import ListDirTool, ReadFileTool, ReadManyFilesTool
# Write tools
from app.tools.filesystem import DeleteFileTool, MakeDirTool, WriteFileTool
@@ -119,7 +122,8 @@ def create_default_registry(
registry = ToolRegistry()
# Read
registry.register(ReadFileTool(workspace_root, config))
registry.register(ReadFileTool(workspace_root, config, file_cache=file_cache))
registry.register(ReadManyFilesTool(workspace_root, config, file_cache=file_cache))
registry.register(ListDirTool(workspace_root, config))
# Search
@@ -127,13 +131,13 @@ def create_default_registry(
registry.register(FindFilesTool(workspace_root, config))
# Write
registry.register(WriteFileTool(workspace_root, config))
registry.register(WriteFileTool(workspace_root, config, file_cache=file_cache))
registry.register(MakeDirTool(workspace_root, config))
registry.register(DeleteFileTool(workspace_root, config))
registry.register(DeleteFileTool(workspace_root, config, file_cache=file_cache))
# Edit
registry.register(StrReplaceTool(workspace_root, config))
registry.register(PatchApplyTool(workspace_root, config))
registry.register(StrReplaceTool(workspace_root, config, file_cache=file_cache))
registry.register(PatchApplyTool(workspace_root, config, file_cache=file_cache))
# Shell
registry.register(RunCommandTool(workspace_root, config))

View File

@@ -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)
@@ -97,11 +100,20 @@ class SneakyCodeApp(App):
self._config.skills, self._config.agent.workspace_root
)
# Create file cache if enabled
self._file_cache = None
fs_cache_cfg = self._config.tools.filesystem.cache
if fs_cache_cfg.enabled:
from app.utils.file_cache import FileCache
self._file_cache = FileCache(max_entries=fs_cache_cfg.max_entries)
# Create tool registry (SkillRunner wired after registry exists)
self._tool_registry = create_default_registry(
self._config.agent.workspace_root,
self._config,
skills_manager=self._skills_manager,
file_cache=self._file_cache,
)
# Create SkillRunner and late-bind it to skill tools
@@ -189,8 +201,8 @@ class SneakyCodeApp(App):
table.add_row("/history", "Show conversation history")
table.add_row("/save", "Manually save session")
table.add_row("/session", "Show session info (messages, tokens, start time)")
table.add_row("/models", "List available Ollama models")
table.add_row("/models <name>", "Switch to a different model")
table.add_row("/models, /model", "List available Ollama models")
table.add_row("/model <name>", "Switch to a different model")
table.add_row("/mode", "Show current agent mode")
table.add_row("/mode normal|plan|auto", "Switch agent mode")
table.add_row("/skills", "List available skills")
@@ -222,7 +234,7 @@ class SneakyCodeApp(App):
f"Started: {self._ctx.start_time.isoformat()}",
style="cyan",
))
elif cmd.startswith("/models"):
elif cmd.split()[0] in ("/models", "/model"):
parts = command.split(maxsplit=1)
if len(parts) == 1:
# List available models
@@ -246,9 +258,30 @@ 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"))
elif cmd.startswith("/mode"):
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:
current = self._permissions.mode

View File

@@ -55,8 +55,8 @@ Screen {
Input {
dock: bottom;
margin: 0;
border: none;
padding: 0 1;
border: heavy darkcyan;
padding: 0;
}
/* HeaderPanel styles are in DEFAULT_CSS on the widget itself */

View File

@@ -26,7 +26,7 @@ class HeaderPanel(Static):
HeaderPanel {
dock: top;
height: 1;
background: $accent;
background: darkcyan;
color: $text;
padding: 0 2;
}

185
app/utils/file_cache.py Normal file
View File

@@ -0,0 +1,185 @@
"""File cache with LRU eviction and mtime-based invalidation."""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field
from pathlib import Path
from app.utils.file_helpers import (
BinaryFileError,
FileSizeError,
PathSecurityError,
check_file_size,
is_binary_file,
resolve_safe_path,
)
@dataclass(slots=True)
class CacheEntry:
"""A cached file's content and modification timestamp."""
content: str
mtime_ns: int
@dataclass
class CacheStats:
"""Running statistics for a FileCache instance."""
hits: int = 0
misses: int = 0
invalidations: int = 0
evictions: int = 0
@property
def hit_rate(self) -> float:
"""Return cache hit rate as a float between 0.0 and 1.0."""
total = self.hits + self.misses
if total == 0:
return 0.0
return self.hits / total
class FileCache:
"""LRU file-content cache with mtime-based invalidation.
Keyed by resolved absolute ``Path``. Each lookup performs a cheap
``stat()`` syscall to verify the file hasn't changed on disk — if the
nanosecond mtime differs the entry is evicted and the caller gets a
cache miss.
Not thread-safe (single-threaded agent loop).
"""
def __init__(self, max_entries: int = 128) -> None:
self._max_entries = max_entries
self._entries: OrderedDict[Path, CacheEntry] = OrderedDict()
self._stats = CacheStats()
# -- public API --------------------------------------------------
def get(self, path: Path) -> str | None:
"""Return cached content if *path* hasn't changed, else ``None``.
A ``stat()`` call checks ``st_mtime_ns``; on mismatch the stale
entry is silently removed.
"""
entry = self._entries.get(path)
if entry is None:
self._stats.misses += 1
return None
try:
current_mtime_ns = path.stat().st_mtime_ns
except OSError:
# File gone — evict and miss.
self._remove(path)
self._stats.misses += 1
return None
if current_mtime_ns != entry.mtime_ns:
self._remove(path)
self._stats.invalidations += 1
self._stats.misses += 1
return None
# Cache hit — move to end (most-recently used).
self._entries.move_to_end(path)
self._stats.hits += 1
return entry.content
def put(self, path: Path, content: str) -> None:
"""Store *content* for *path* with its current ``st_mtime_ns``.
Evicts the least-recently-used entry when over capacity.
"""
try:
mtime_ns = path.stat().st_mtime_ns
except OSError:
# Can't stat — don't cache.
return
if path in self._entries:
# Update existing; move to end.
self._entries[path] = CacheEntry(content=content, mtime_ns=mtime_ns)
self._entries.move_to_end(path)
else:
self._entries[path] = CacheEntry(content=content, mtime_ns=mtime_ns)
# Evict LRU if over capacity.
while len(self._entries) > self._max_entries:
self._entries.popitem(last=False)
self._stats.evictions += 1
def invalidate(self, path: Path) -> None:
"""Remove *path* from the cache if present."""
if path in self._entries:
del self._entries[path]
self._stats.invalidations += 1
def clear(self) -> None:
"""Remove all entries."""
self._entries.clear()
@property
def stats(self) -> CacheStats:
"""Return the running cache statistics."""
return self._stats
def __len__(self) -> int:
return len(self._entries)
# -- internals ---------------------------------------------------
def _remove(self, path: Path) -> None:
"""Delete an entry without bumping invalidation stats."""
self._entries.pop(path, None)
def cached_read_file(
path: str | Path,
workspace_root: Path,
max_size_bytes: int = 1_048_576,
check_binary: bool = True,
cache: FileCache | None = None,
) -> str:
"""Read a file with full security checks, using *cache* when available.
Security checks (path sandboxing, size limit, binary detection) run on
**every** call — only the ``Path.read_text()`` I/O is skipped on a cache
hit.
When *cache* is ``None`` this behaves identically to
:func:`~app.utils.file_helpers.safe_read_file`.
Raises:
PathSecurityError: If the path escapes the workspace.
FileSizeError: If the file is too large.
BinaryFileError: If the file is binary and *check_binary* is True.
FileNotFoundError: If the file does not exist.
"""
safe_path = resolve_safe_path(path, workspace_root)
if not safe_path.exists():
raise FileNotFoundError(f"File not found: {safe_path}")
check_file_size(safe_path, max_size_bytes)
if check_binary and is_binary_file(safe_path):
raise BinaryFileError(f"File appears to be binary: {safe_path}")
# Try cache.
if cache is not None:
cached = cache.get(safe_path)
if cached is not None:
return cached
# Cache miss (or no cache) — read from disk.
content = safe_path.read_text(encoding="utf-8")
if cache is not None:
cache.put(safe_path, content)
return content

View File

@@ -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."""

View File

@@ -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
@@ -68,6 +81,9 @@ tools:
filesystem:
max_file_size_bytes: 1048576 # 1 MB
binary_detection: true
cache:
enabled: true
max_entries: 128
session:
session_dir: ".sneakycode/sessions"

View 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?

View File

@@ -0,0 +1,314 @@
"""Tests for the file cache with LRU eviction and mtime invalidation."""
import os
import time
from pathlib import Path
import pytest
from app.models.config import AppConfig, load_config
from app.models.tool_call import ToolResultStatus
from app.tools.filesystem import ReadFileTool, ReadManyFilesTool
from app.utils.file_cache import CacheStats, FileCache, cached_read_file
from app.utils.file_helpers import BinaryFileError, FileSizeError, PathSecurityError
# ---------------------------------------------------------------------------
# FileCache unit tests
# ---------------------------------------------------------------------------
class TestFileCache:
def test_put_and_get_roundtrip(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "hello.txt"
f.write_text("hello world")
cache.put(f, "hello world")
assert cache.get(f) == "hello world"
def test_get_returns_none_for_missing_key(self, tmp_path: Path) -> None:
cache = FileCache()
assert cache.get(tmp_path / "nope.txt") is None
def test_mtime_change_causes_miss(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "data.txt"
f.write_text("v1")
cache.put(f, "v1")
# Mutate the file so mtime changes
time.sleep(0.05) # ensure mtime differs
f.write_text("v2")
assert cache.get(f) is None # stale → miss
assert cache.stats.invalidations == 1
def test_lru_eviction_at_capacity(self, tmp_path: Path) -> None:
cache = FileCache(max_entries=3)
files = []
for i in range(4):
f = tmp_path / f"f{i}.txt"
f.write_text(f"content-{i}")
files.append(f)
# Fill cache to capacity
for f in files[:3]:
cache.put(f, f.read_text())
assert len(cache) == 3
# Adding a 4th evicts the LRU (files[0])
cache.put(files[3], files[3].read_text())
assert len(cache) == 3
assert cache.get(files[0]) is None # evicted
assert cache.stats.evictions == 1
# files[1..3] still present
for f in files[1:]:
assert cache.get(f) is not None
def test_invalidate_removes_entry(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "rm.txt"
f.write_text("bye")
cache.put(f, "bye")
assert len(cache) == 1
cache.invalidate(f)
assert len(cache) == 0
assert cache.get(f) is None
assert cache.stats.invalidations == 1
def test_invalidate_noop_for_missing(self) -> None:
cache = FileCache()
cache.invalidate(Path("/nonexistent"))
assert cache.stats.invalidations == 0
def test_clear_empties_cache(self, tmp_path: Path) -> None:
cache = FileCache()
for i in range(5):
f = tmp_path / f"c{i}.txt"
f.write_text(str(i))
cache.put(f, str(i))
assert len(cache) == 5
cache.clear()
assert len(cache) == 0
def test_stats_accuracy(self, tmp_path: Path) -> None:
cache = FileCache(max_entries=2)
a = tmp_path / "a.txt"
b = tmp_path / "b.txt"
c = tmp_path / "c.txt"
a.write_text("a")
b.write_text("b")
c.write_text("c")
# Miss
cache.get(a)
assert cache.stats.misses == 1
assert cache.stats.hits == 0
# Put + hit
cache.put(a, "a")
cache.get(a)
assert cache.stats.hits == 1
# Fill + evict
cache.put(b, "b")
cache.put(c, "c") # evicts a
assert cache.stats.evictions == 1
def test_hit_rate(self) -> None:
stats = CacheStats(hits=3, misses=1)
assert stats.hit_rate == pytest.approx(0.75)
def test_hit_rate_zero_total(self) -> None:
stats = CacheStats()
assert stats.hit_rate == 0.0
def test_file_deleted_after_caching(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "gone.txt"
f.write_text("here")
cache.put(f, "here")
f.unlink()
assert cache.get(f) is None # stat fails → miss
def test_put_skips_when_stat_fails(self) -> None:
cache = FileCache()
cache.put(Path("/totally/nonexistent"), "data")
assert len(cache) == 0
def test_get_moves_to_end(self, tmp_path: Path) -> None:
"""Accessing an entry makes it most-recently-used, protecting from eviction."""
cache = FileCache(max_entries=3)
files = []
for i in range(3):
f = tmp_path / f"lru{i}.txt"
f.write_text(f"c{i}")
files.append(f)
cache.put(f, f"c{i}")
# Touch files[0] to make it MRU
cache.get(files[0])
# Add a new entry — files[1] (LRU) should be evicted, not files[0]
extra = tmp_path / "extra.txt"
extra.write_text("x")
cache.put(extra, "x")
assert cache.get(files[0]) is not None # protected by access
assert cache.get(files[1]) is None # evicted
# ---------------------------------------------------------------------------
# cached_read_file tests
# ---------------------------------------------------------------------------
class TestCachedReadFile:
def test_without_cache_matches_safe_read(self, tmp_path: Path) -> None:
f = tmp_path / "plain.txt"
f.write_text("hello")
content = cached_read_file(f, tmp_path, cache=None)
assert content == "hello"
def test_populates_on_miss_returns_on_hit(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "cached.txt"
f.write_text("data")
# First call: miss → read from disk → populate cache
content1 = cached_read_file(f, tmp_path, cache=cache)
assert content1 == "data"
assert cache.stats.misses == 1
assert cache.stats.hits == 0
# Second call: hit → from cache
content2 = cached_read_file(f, tmp_path, cache=cache)
assert content2 == "data"
assert cache.stats.hits == 1
def test_security_checks_run_on_cached_path(self, tmp_path: Path) -> None:
cache = FileCache()
with pytest.raises(PathSecurityError):
cached_read_file("/etc/passwd", tmp_path, cache=cache)
def test_binary_check_runs_on_cached_path(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "bin.dat"
f.write_bytes(b"\x00binary\x00")
with pytest.raises(BinaryFileError):
cached_read_file(f, tmp_path, cache=cache)
def test_size_check_runs_on_cached_path(self, tmp_path: Path) -> None:
cache = FileCache()
f = tmp_path / "big.txt"
f.write_text("x" * 200)
# First read populates cache
cached_read_file(f, tmp_path, max_size_bytes=1000, cache=cache)
# Now make file too big on disk — security check should catch it
# even though content is cached
f.write_text("x" * 2000)
with pytest.raises(FileSizeError):
cached_read_file(f, tmp_path, max_size_bytes=1000, cache=cache)
def test_file_not_found(self, tmp_path: Path) -> None:
cache = FileCache()
with pytest.raises(FileNotFoundError):
cached_read_file(tmp_path / "nope.txt", tmp_path, cache=cache)
# ---------------------------------------------------------------------------
# Tool-level cache-hit dedup tests
# ---------------------------------------------------------------------------
@pytest.fixture
def config() -> AppConfig:
return load_config()
@pytest.fixture
def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]:
config.agent.workspace_root = tmp_path
return tmp_path, config
class TestReadFileToolCacheHit:
def test_first_read_returns_full_content(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
cache = FileCache()
(ws / "hello.txt").write_text("hello world")
tool = ReadFileTool(ws, cfg, file_cache=cache)
result = tool.run("tc-1", {"file_path": "hello.txt"})
assert result.status == ToolResultStatus.SUCCESS
assert result.output == "hello world"
def test_second_read_returns_cached_message(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
cache = FileCache()
(ws / "hello.txt").write_text("hello world")
tool = ReadFileTool(ws, cfg, file_cache=cache)
tool.run("tc-1", {"file_path": "hello.txt"})
result2 = tool.run("tc-2", {"file_path": "hello.txt"})
assert result2.status == ToolResultStatus.SUCCESS
assert "[Cached]" in result2.output
assert "hello.txt" in result2.output
assert "hello world" not in result2.output
def test_changed_file_returns_full_content_again(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
cache = FileCache()
f = ws / "data.txt"
f.write_text("v1")
tool = ReadFileTool(ws, cfg, file_cache=cache)
tool.run("tc-1", {"file_path": "data.txt"})
# Mutate file so mtime changes
time.sleep(0.05)
f.write_text("v2")
result2 = tool.run("tc-2", {"file_path": "data.txt"})
assert result2.status == ToolResultStatus.SUCCESS
assert result2.output == "v2"
assert "[Cached]" not in result2.output
def test_no_cache_always_returns_content(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "hello.txt").write_text("hello")
tool = ReadFileTool(ws, cfg, file_cache=None)
r1 = tool.run("tc-1", {"file_path": "hello.txt"})
r2 = tool.run("tc-2", {"file_path": "hello.txt"})
assert r1.output == "hello"
assert r2.output == "hello"
class TestReadManyFilesToolCacheHit:
def test_cached_files_get_short_message(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
cache = FileCache()
(ws / "a.txt").write_text("alpha")
(ws / "b.txt").write_text("bravo")
tool = ReadManyFilesTool(ws, cfg, file_cache=cache)
# First read — full content
r1 = tool.run("tc-1", {"file_paths": ["a.txt", "b.txt"]})
assert "alpha" in r1.output
assert "bravo" in r1.output
# Second read — cached messages
r2 = tool.run("tc-2", {"file_paths": ["a.txt", "b.txt"]})
assert "[Cached]" in r2.output
assert "alpha" not in r2.output
assert "bravo" not in r2.output

View File

@@ -0,0 +1,69 @@
"""Tests for the read_many_files tool."""
from pathlib import Path
import pytest
from app.models.config import AppConfig, load_config
from app.models.tool_call import ToolResultStatus
from app.tools.filesystem import ReadManyFilesTool
@pytest.fixture
def config() -> AppConfig:
return load_config()
@pytest.fixture
def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]:
"""Create a temporary workspace for read_many_files tests."""
config.agent.workspace_root = tmp_path
return tmp_path, config
class TestReadManyFilesTool:
def test_read_multiple_files(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "a.txt").write_text("alpha")
(ws / "b.txt").write_text("bravo")
tool = ReadManyFilesTool(ws, cfg)
result = tool.run("tc-1", {"file_paths": ["a.txt", "b.txt"]})
assert result.status == ToolResultStatus.SUCCESS
assert "=== a.txt ===" in result.output
assert "alpha" in result.output
assert "=== b.txt ===" in result.output
assert "bravo" in result.output
def test_partial_failure(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "exists.txt").write_text("hello")
tool = ReadManyFilesTool(ws, cfg)
result = tool.run("tc-2", {"file_paths": ["exists.txt", "missing.txt"]})
assert result.status == ToolResultStatus.SUCCESS
assert "hello" in result.output
assert "[ERROR]" in result.output
assert "=== missing.txt ===" in result.output
def test_all_files_fail(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = ReadManyFilesTool(ws, cfg)
result = tool.run("tc-3", {"file_paths": ["no1.txt", "no2.txt"]})
assert result.status == ToolResultStatus.ERROR
assert "All files failed" in (result.error or "")
def test_empty_file_paths(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
tool = ReadManyFilesTool(ws, cfg)
result = tool.run("tc-4", {"file_paths": []})
assert result.status == ToolResultStatus.ERROR
assert "empty" in (result.error or "").lower()
def test_path_security_inline_error(self, tmp_workspace: tuple[Path, AppConfig]) -> None:
ws, cfg = tmp_workspace
(ws / "safe.txt").write_text("ok")
tool = ReadManyFilesTool(ws, cfg)
result = tool.run("tc-5", {"file_paths": ["safe.txt", "../../etc/passwd"]})
assert result.status == ToolResultStatus.SUCCESS
assert "ok" in result.output
assert "[ERROR]" in result.output
assert "outside" in result.output.lower()

View File

@@ -108,7 +108,7 @@ class TestToolRegistry:
registry = create_default_registry(workspace, config)
names = set(registry.get_all().keys())
assert names == {
"read_file", "list_dir", "grep_files", "find_files",
"read_file", "read_many_files", "list_dir", "grep_files", "find_files",
"write_file", "make_dir", "delete_file",
"str_replace", "patch_apply",
"run_command",
@@ -118,7 +118,7 @@ class TestToolRegistry:
def test_schema_export(self, workspace: Path, config: AppConfig) -> None:
registry = create_default_registry(workspace, config)
schemas = registry.get_openai_tools_schema()
assert len(schemas) == 11
assert len(schemas) == 12
assert all(s["type"] == "function" for s in schemas)