Compare commits
9 Commits
0886727437
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d79df421 | |||
| 1ee721ac10 | |||
| d54a3480b8 | |||
| d3b286ba40 | |||
| d829e6553c | |||
| 2c532adbbc | |||
| be1ea81102 | |||
| 3fe0f7af47 | |||
| 05754fe06b |
@@ -105,17 +105,25 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
return prompt
|
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]:
|
def _get_messages_with_system_prompt(self) -> list[Message]:
|
||||||
"""Prepend the system prompt to conversation history.
|
"""Prepend the system prompt to conversation history.
|
||||||
|
|
||||||
When thinking is disabled, appends a system-level /no_think directive
|
When thinking is disabled on a model that supports it, appends a
|
||||||
after the last user message so Qwen 3.x (and similar) chat templates
|
system-level /no_think directive after the last user message so
|
||||||
see it, without polluting the user's actual message content.
|
Qwen 3.x (and similar) chat templates see it.
|
||||||
"""
|
"""
|
||||||
system_msg = Message(role="system", content=self._system_prompt)
|
system_msg = Message(role="system", content=self._system_prompt)
|
||||||
history = self._ctx.get_history()
|
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)
|
history = list(history)
|
||||||
# Find last user message and insert a system hint after it
|
# Find last user message and insert a system hint after it
|
||||||
for i in range(len(history) - 1, -1, -1):
|
for i in range(len(history) - 1, -1, -1):
|
||||||
@@ -140,6 +148,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
max_iter = self._config.agent.max_iterations
|
max_iter = self._config.agent.max_iterations
|
||||||
reasoning_only_streak = 0
|
reasoning_only_streak = 0
|
||||||
|
empty_streak = 0
|
||||||
for iteration in range(1, max_iter + 1):
|
for iteration in range(1, max_iter + 1):
|
||||||
if self._cancelled:
|
if self._cancelled:
|
||||||
if self._display:
|
if self._display:
|
||||||
@@ -230,6 +239,36 @@ class AgentLoop:
|
|||||||
# Successful response — reset streak
|
# Successful response — reset streak
|
||||||
reasoning_only_streak = 0
|
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)
|
# Display any assistant text content (even if tool calls follow)
|
||||||
if self._display and assistant_msg.content:
|
if self._display and assistant_msg.content:
|
||||||
self._display.write_assistant_message(assistant_msg.content)
|
self._display.write_assistant_message(assistant_msg.content)
|
||||||
@@ -263,21 +302,25 @@ class AgentLoop:
|
|||||||
if self._display:
|
if self._display:
|
||||||
self._display.write_warning(f"Agent reached maximum iterations ({max_iter}). Stopping.")
|
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.
|
"""Stream one LLM response and return the accumulated Message.
|
||||||
|
|
||||||
Uses retry-enabled streaming. On mid-stream errors, attempts to recover
|
Uses retry-enabled streaming. On mid-stream errors, attempts to recover
|
||||||
partial content if available.
|
partial content if available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip_tools: If True, send the request without tool schemas (fallback mode).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The assistant Message, or None if an error occurred.
|
The assistant Message, or None if an error occurred.
|
||||||
"""
|
"""
|
||||||
messages = self._get_messages_with_system_prompt()
|
messages = self._get_messages_with_system_prompt()
|
||||||
if self._debug:
|
if self._debug:
|
||||||
self._debug.log_request(messages, self._config.llm.model)
|
self._debug.log_request(messages, self._config.llm.model)
|
||||||
|
tools = None if skip_tools else self._tools_schema
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
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)
|
result = await self._handler.process_stream(chunk_iter)
|
||||||
if result and self._debug:
|
if result and self._debug:
|
||||||
elapsed = (time.monotonic() - t0) * 1000
|
elapsed = (time.monotonic() - t0) * 1000
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ class AgentMode(StrEnum):
|
|||||||
AUTO = "auto"
|
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):
|
class LLMConfig(BaseModel):
|
||||||
"""LLM backend configuration."""
|
"""LLM backend configuration."""
|
||||||
|
|
||||||
@@ -73,11 +90,19 @@ class ShellToolConfig(BaseModel):
|
|||||||
max_output_bytes: int = Field(default=65536, description="Max output capture size in bytes")
|
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):
|
class FilesystemToolConfig(BaseModel):
|
||||||
"""Filesystem tool limits."""
|
"""Filesystem tool limits."""
|
||||||
|
|
||||||
max_file_size_bytes: int = Field(default=1_048_576, description="Max file size for read/write")
|
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")
|
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):
|
class ToolsConfig(BaseModel):
|
||||||
@@ -137,6 +162,10 @@ class AppConfig(BaseModel):
|
|||||||
session: SessionConfig = Field(default_factory=SessionConfig)
|
session: SessionConfig = Field(default_factory=SessionConfig)
|
||||||
debug: DebugConfig = Field(default_factory=DebugConfig)
|
debug: DebugConfig = Field(default_factory=DebugConfig)
|
||||||
skills: SkillsConfig = Field(default_factory=SkillsConfig)
|
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")
|
@model_validator(mode="after")
|
||||||
def resolve_workspace_root(self) -> "AppConfig":
|
def resolve_workspace_root(self) -> "AppConfig":
|
||||||
@@ -144,6 +173,39 @@ class AppConfig(BaseModel):
|
|||||||
self.agent.workspace_root = self.agent.workspace_root.resolve()
|
self.agent.workspace_root = self.agent.workspace_root.resolve()
|
||||||
return self
|
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 file location relative to project root
|
||||||
_DEFAULT_CONFIG_PATH = Path("config/config.yaml")
|
_DEFAULT_CONFIG_PATH = Path("config/config.yaml")
|
||||||
|
|||||||
@@ -151,8 +151,9 @@ class LLMClient:
|
|||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
|
|
||||||
# When thinking is disabled, inject chat_template_kwargs for backends that support it
|
# When thinking is disabled, inject chat_template_kwargs for backends
|
||||||
if not self._config.thinking:
|
# 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
|
payload.setdefault("chat_template_kwargs", {})["enable_thinking"] = False
|
||||||
|
|
||||||
# Merge model-specific extra parameters (e.g., reasoning_effort)
|
# Merge model-specific extra parameters (e.g., reasoning_effort)
|
||||||
@@ -170,20 +171,32 @@ class LLMClient:
|
|||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
chunk_count = 0
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.startswith("data: "):
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
data = line[6:] # strip "data: " prefix
|
# SSE format: "data: {json}" or "data: [DONE]"
|
||||||
|
if line.startswith("data: "):
|
||||||
if data.strip() == "[DONE]":
|
data = line[6:]
|
||||||
return
|
if data.strip() == "[DONE]":
|
||||||
|
break
|
||||||
|
elif line.startswith("{"):
|
||||||
|
# Plain NDJSON fallback (some Ollama versions)
|
||||||
|
data = line
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield json.loads(data)
|
yield json.loads(data)
|
||||||
|
chunk_count += 1
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning("malformed_sse_chunk", data=data[:200])
|
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:
|
except httpx.ConnectError as e:
|
||||||
raise LLMConnectionError(f"Cannot connect to LLM endpoint: {e}") from e
|
raise LLMConnectionError(f"Cannot connect to LLM endpoint: {e}") from e
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class SessionManager:
|
|||||||
self._session_dir = workspace_root / config.session_dir
|
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')}"
|
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:
|
def save(self, ctx: "SessionContext") -> Path:
|
||||||
"""Save session state to a JSON file via atomic write.
|
"""Save session state to a JSON file via atomic write.
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,10 @@ class StreamHandler:
|
|||||||
"""
|
"""
|
||||||
thinking_notified = False
|
thinking_notified = False
|
||||||
last_update_time = 0.0
|
last_update_time = 0.0
|
||||||
|
chunk_count = 0
|
||||||
|
|
||||||
async for chunk in chunk_iter:
|
async for chunk in chunk_iter:
|
||||||
|
chunk_count += 1
|
||||||
self._process_chunk(chunk)
|
self._process_chunk(chunk)
|
||||||
|
|
||||||
if not self._display_config.stream_output:
|
if not self._display_config.stream_output:
|
||||||
@@ -96,6 +98,14 @@ class StreamHandler:
|
|||||||
self._on_done()
|
self._on_done()
|
||||||
|
|
||||||
tool_calls = self._build_tool_calls() or None
|
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(
|
return Message(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=self._accumulated_content or None,
|
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
|
return bool(self._accumulated_reasoning) and not self._accumulated_content and not self._tool_calls
|
||||||
|
|
||||||
def reset(self) -> None:
|
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_content = ""
|
||||||
self._accumulated_reasoning = ""
|
self._accumulated_reasoning = ""
|
||||||
self._tool_calls.clear()
|
self._tool_calls.clear()
|
||||||
self._usage = None
|
self._usage = None
|
||||||
self._on_content = None
|
|
||||||
self._on_thinking = None
|
|
||||||
self._on_done = None
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""Edit tools: str_replace and patch_apply."""
|
"""Edit tools: str_replace and patch_apply."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.config import AppConfig
|
||||||
from app.models.tool_call import ToolResult, ToolResultStatus
|
from app.models.tool_call import ToolResult, ToolResultStatus
|
||||||
from app.tools.base import BaseTool
|
from app.tools.base import BaseTool
|
||||||
|
from app.utils.file_cache import FileCache, cached_read_file
|
||||||
from app.utils.file_helpers import (
|
from app.utils.file_helpers import (
|
||||||
FileSizeError,
|
FileSizeError,
|
||||||
PathSecurityError,
|
PathSecurityError,
|
||||||
@@ -37,6 +41,12 @@ class StrReplaceTool(BaseTool):
|
|||||||
)
|
)
|
||||||
params_model = StrReplaceParams
|
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(
|
def execute(
|
||||||
self, *, tool_call_id: str, file_path: str, old_str: str, new_str: str, **kwargs: Any
|
self, *, tool_call_id: str, file_path: str, old_str: str, new_str: str, **kwargs: Any
|
||||||
) -> ToolResult:
|
) -> ToolResult:
|
||||||
@@ -44,11 +54,12 @@ class StrReplaceTool(BaseTool):
|
|||||||
|
|
||||||
# Read the file
|
# Read the file
|
||||||
try:
|
try:
|
||||||
content = safe_read_file(
|
content = cached_read_file(
|
||||||
file_path,
|
file_path,
|
||||||
self.workspace_root,
|
self.workspace_root,
|
||||||
max_size_bytes=fs_config.max_file_size_bytes,
|
max_size_bytes=fs_config.max_file_size_bytes,
|
||||||
check_binary=fs_config.binary_detection,
|
check_binary=fs_config.binary_detection,
|
||||||
|
cache=self._file_cache,
|
||||||
)
|
)
|
||||||
except PathSecurityError as exc:
|
except PathSecurityError as exc:
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
@@ -117,8 +128,14 @@ class StrReplaceTool(BaseTool):
|
|||||||
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
||||||
rel_path = safe_path.relative_to(self.workspace_root)
|
rel_path = safe_path.relative_to(self.workspace_root)
|
||||||
except (PathSecurityError, ValueError):
|
except (PathSecurityError, ValueError):
|
||||||
|
safe_path = None
|
||||||
rel_path = Path(file_path)
|
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(
|
return ToolResult(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
tool_name=self.name,
|
tool_name=self.name,
|
||||||
@@ -144,6 +161,12 @@ class PatchApplyTool(BaseTool):
|
|||||||
)
|
)
|
||||||
params_model = PatchApplyParams
|
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:
|
def execute(self, *, tool_call_id: str, file_path: str, patch: str, **kwargs: Any) -> ToolResult:
|
||||||
try:
|
try:
|
||||||
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
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}",
|
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:
|
try:
|
||||||
rel_path = safe_path.relative_to(self.workspace_root)
|
rel_path = safe_path.relative_to(self.workspace_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Filesystem tools: read_file, list_dir, write_file, make_dir, delete_file."""
|
"""Filesystem tools: read_file, list_dir, write_file, make_dir, delete_file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.config import AppConfig
|
||||||
from app.models.tool_call import ToolResult, ToolResultStatus
|
from app.models.tool_call import ToolResult, ToolResultStatus
|
||||||
from app.tools.base import BaseTool
|
from app.tools.base import BaseTool
|
||||||
|
from app.utils.file_cache import FileCache, cached_read_file
|
||||||
from app.utils.file_helpers import (
|
from app.utils.file_helpers import (
|
||||||
BinaryFileError,
|
BinaryFileError,
|
||||||
FileSizeError,
|
FileSizeError,
|
||||||
@@ -23,6 +27,12 @@ class ReadFileParams(BaseModel):
|
|||||||
file_path: str = Field(description="Path to the file to read (relative to workspace root)")
|
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):
|
class ReadFileTool(BaseTool):
|
||||||
"""Read the contents of a file within the workspace."""
|
"""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."
|
description = "Read the full contents of a text file. Returns the file content as a string."
|
||||||
params_model = ReadFileParams
|
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:
|
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
|
||||||
fs_config = self.config.tools.filesystem
|
fs_config = self.config.tools.filesystem
|
||||||
|
hits_before = self._file_cache.stats.hits if self._file_cache else 0
|
||||||
try:
|
try:
|
||||||
content = safe_read_file(
|
content = cached_read_file(
|
||||||
file_path,
|
file_path,
|
||||||
self.workspace_root,
|
self.workspace_root,
|
||||||
max_size_bytes=fs_config.max_file_size_bytes,
|
max_size_bytes=fs_config.max_file_size_bytes,
|
||||||
check_binary=fs_config.binary_detection,
|
check_binary=fs_config.binary_detection,
|
||||||
|
cache=self._file_cache,
|
||||||
)
|
)
|
||||||
except PathSecurityError as exc:
|
except PathSecurityError as exc:
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
@@ -47,11 +65,12 @@ class ReadFileTool(BaseTool):
|
|||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
|
filename = Path(file_path).name
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
tool_name=self.name,
|
tool_name=self.name,
|
||||||
status=ToolResultStatus.ERROR,
|
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:
|
except FileSizeError as exc:
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
@@ -68,6 +87,23 @@ class ReadFileTool(BaseTool):
|
|||||||
error=str(exc),
|
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(
|
return ToolResult(
|
||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
tool_name=self.name,
|
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):
|
class ListDirParams(BaseModel):
|
||||||
"""Parameters for the list_dir tool."""
|
"""Parameters for the list_dir tool."""
|
||||||
|
|
||||||
@@ -167,6 +273,12 @@ class WriteFileTool(BaseTool):
|
|||||||
)
|
)
|
||||||
params_model = WriteFileParams
|
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:
|
def execute(self, *, tool_call_id: str, file_path: str, content: str, **kwargs: Any) -> ToolResult:
|
||||||
fs_config = self.config.tools.filesystem
|
fs_config = self.config.tools.filesystem
|
||||||
try:
|
try:
|
||||||
@@ -191,6 +303,9 @@ class WriteFileTool(BaseTool):
|
|||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self._file_cache is not None:
|
||||||
|
self._file_cache.invalidate(safe_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rel_path = safe_path.relative_to(self.workspace_root)
|
rel_path = safe_path.relative_to(self.workspace_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -272,6 +387,12 @@ class DeleteFileTool(BaseTool):
|
|||||||
description = "Delete a single file. Does not delete directories."
|
description = "Delete a single file. Does not delete directories."
|
||||||
params_model = DeleteFileParams
|
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:
|
def execute(self, *, tool_call_id: str, file_path: str, **kwargs: Any) -> ToolResult:
|
||||||
try:
|
try:
|
||||||
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
safe_path = resolve_safe_path(file_path, self.workspace_root)
|
||||||
@@ -309,6 +430,9 @@ class DeleteFileTool(BaseTool):
|
|||||||
error=f"Failed to delete file: {exc}",
|
error=f"Failed to delete file: {exc}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self._file_cache is not None:
|
||||||
|
self._file_cache.invalidate(safe_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rel_path = safe_path.relative_to(self.workspace_root)
|
rel_path = safe_path.relative_to(self.workspace_root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from app.models.config import AppConfig
|
from app.models.config import AppConfig
|
||||||
from app.tools.base import BaseTool
|
from app.tools.base import BaseTool
|
||||||
|
from app.utils.file_cache import FileCache
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.services.skills import SkillsManager
|
from app.services.skills import SkillsManager
|
||||||
@@ -89,6 +90,7 @@ def create_default_registry(
|
|||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
skills_manager: SkillsManager | None = None,
|
skills_manager: SkillsManager | None = None,
|
||||||
skill_runner: object | None = None,
|
skill_runner: object | None = None,
|
||||||
|
file_cache: FileCache | None = None,
|
||||||
) -> ToolRegistry:
|
) -> ToolRegistry:
|
||||||
"""Create a ToolRegistry populated with all built-in tools.
|
"""Create a ToolRegistry populated with all built-in tools.
|
||||||
|
|
||||||
@@ -97,9 +99,10 @@ def create_default_registry(
|
|||||||
config: Application configuration.
|
config: Application configuration.
|
||||||
skills_manager: Optional skills manager for skill tools.
|
skills_manager: Optional skills manager for skill tools.
|
||||||
skill_runner: Optional SkillRunner for package skill activation.
|
skill_runner: Optional SkillRunner for package skill activation.
|
||||||
|
file_cache: Optional file cache shared across file-reading tools.
|
||||||
"""
|
"""
|
||||||
# Read tools
|
# Read tools
|
||||||
from app.tools.filesystem import ListDirTool, ReadFileTool
|
from app.tools.filesystem import ListDirTool, ReadFileTool, ReadManyFilesTool
|
||||||
|
|
||||||
# Write tools
|
# Write tools
|
||||||
from app.tools.filesystem import DeleteFileTool, MakeDirTool, WriteFileTool
|
from app.tools.filesystem import DeleteFileTool, MakeDirTool, WriteFileTool
|
||||||
@@ -119,7 +122,8 @@ def create_default_registry(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
|
|
||||||
# Read
|
# 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))
|
registry.register(ListDirTool(workspace_root, config))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
@@ -127,13 +131,13 @@ def create_default_registry(
|
|||||||
registry.register(FindFilesTool(workspace_root, config))
|
registry.register(FindFilesTool(workspace_root, config))
|
||||||
|
|
||||||
# Write
|
# 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(MakeDirTool(workspace_root, config))
|
||||||
registry.register(DeleteFileTool(workspace_root, config))
|
registry.register(DeleteFileTool(workspace_root, config, file_cache=file_cache))
|
||||||
|
|
||||||
# Edit
|
# Edit
|
||||||
registry.register(StrReplaceTool(workspace_root, config))
|
registry.register(StrReplaceTool(workspace_root, config, file_cache=file_cache))
|
||||||
registry.register(PatchApplyTool(workspace_root, config))
|
registry.register(PatchApplyTool(workspace_root, config, file_cache=file_cache))
|
||||||
|
|
||||||
# Shell
|
# Shell
|
||||||
registry.register(RunCommandTool(workspace_root, config))
|
registry.register(RunCommandTool(workspace_root, config))
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ class SneakyCodeApp(App):
|
|||||||
"""Initialize agent components after the app is mounted."""
|
"""Initialize agent components after the app is mounted."""
|
||||||
setup_logging_for_tui()
|
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)
|
self._ctx = SessionContext(self._config)
|
||||||
|
|
||||||
# Create long-lived agent dependencies (reused across turns)
|
# Create long-lived agent dependencies (reused across turns)
|
||||||
@@ -97,11 +100,20 @@ class SneakyCodeApp(App):
|
|||||||
self._config.skills, self._config.agent.workspace_root
|
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)
|
# Create tool registry (SkillRunner wired after registry exists)
|
||||||
self._tool_registry = create_default_registry(
|
self._tool_registry = create_default_registry(
|
||||||
self._config.agent.workspace_root,
|
self._config.agent.workspace_root,
|
||||||
self._config,
|
self._config,
|
||||||
skills_manager=self._skills_manager,
|
skills_manager=self._skills_manager,
|
||||||
|
file_cache=self._file_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create SkillRunner and late-bind it to skill tools
|
# 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("/history", "Show conversation history")
|
||||||
table.add_row("/save", "Manually save session")
|
table.add_row("/save", "Manually save session")
|
||||||
table.add_row("/session", "Show session info (messages, tokens, start time)")
|
table.add_row("/session", "Show session info (messages, tokens, start time)")
|
||||||
table.add_row("/models", "List available Ollama models")
|
table.add_row("/models, /model", "List available Ollama models")
|
||||||
table.add_row("/models <name>", "Switch to a different model")
|
table.add_row("/model <name>", "Switch to a different model")
|
||||||
table.add_row("/mode", "Show current agent mode")
|
table.add_row("/mode", "Show current agent mode")
|
||||||
table.add_row("/mode normal|plan|auto", "Switch agent mode")
|
table.add_row("/mode normal|plan|auto", "Switch agent mode")
|
||||||
table.add_row("/skills", "List available skills")
|
table.add_row("/skills", "List available skills")
|
||||||
@@ -222,7 +234,7 @@ class SneakyCodeApp(App):
|
|||||||
f"Started: {self._ctx.start_time.isoformat()}",
|
f"Started: {self._ctx.start_time.isoformat()}",
|
||||||
style="cyan",
|
style="cyan",
|
||||||
))
|
))
|
||||||
elif cmd.startswith("/models"):
|
elif cmd.split()[0] in ("/models", "/model"):
|
||||||
parts = command.split(maxsplit=1)
|
parts = command.split(maxsplit=1)
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
# List available models
|
# List available models
|
||||||
@@ -246,9 +258,30 @@ class SneakyCodeApp(App):
|
|||||||
else:
|
else:
|
||||||
new_model = parts[1].strip()
|
new_model = parts[1].strip()
|
||||||
self._config.llm.model = new_model
|
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)
|
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)
|
||||||
elif cmd.startswith("/mode"):
|
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)
|
parts = command.split(maxsplit=1)
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
current = self._permissions.mode
|
current = self._permissions.mode
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class HeaderPanel(Static):
|
|||||||
HeaderPanel {
|
HeaderPanel {
|
||||||
dock: top;
|
dock: top;
|
||||||
height: 1;
|
height: 1;
|
||||||
background: $accent;
|
background: darkcyan;
|
||||||
color: $text;
|
color: $text;
|
||||||
padding: 0 2;
|
padding: 0 2;
|
||||||
}
|
}
|
||||||
|
|||||||
185
app/utils/file_cache.py
Normal file
185
app/utils/file_cache.py
Normal 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
|
||||||
@@ -36,6 +36,11 @@ class TokenCounter:
|
|||||||
"""The configured token budget."""
|
"""The configured token budget."""
|
||||||
return self._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
|
@property
|
||||||
def cumulative_usage(self) -> TokenUsage:
|
def cumulative_usage(self) -> TokenUsage:
|
||||||
"""Cumulative token usage across all tracked calls."""
|
"""Cumulative token usage across all tracked calls."""
|
||||||
|
|||||||
@@ -18,11 +18,24 @@ llm:
|
|||||||
|
|
||||||
agent:
|
agent:
|
||||||
max_iterations: 25
|
max_iterations: 25
|
||||||
max_conversation_tokens: 32000
|
max_conversation_tokens: 32000 # Default token budget (overridden by model_profiles)
|
||||||
workspace_root: "."
|
workspace_root: "."
|
||||||
truncation_keep_recent: 10
|
truncation_keep_recent: 10
|
||||||
truncation_threshold: 0.85
|
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:
|
permissions:
|
||||||
auto_approve:
|
auto_approve:
|
||||||
- read_file
|
- read_file
|
||||||
@@ -68,6 +81,9 @@ tools:
|
|||||||
filesystem:
|
filesystem:
|
||||||
max_file_size_bytes: 1048576 # 1 MB
|
max_file_size_bytes: 1048576 # 1 MB
|
||||||
binary_detection: true
|
binary_detection: true
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
max_entries: 128
|
||||||
|
|
||||||
session:
|
session:
|
||||||
session_dir: ".sneakycode/sessions"
|
session_dir: ".sneakycode/sessions"
|
||||||
|
|||||||
@@ -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
|
How do we handle huge files not taking up so many tokens?
|
||||||
|
|
||||||
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)
|
|
||||||
314
tests/unit/test_file_cache.py
Normal file
314
tests/unit/test_file_cache.py
Normal 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
|
||||||
69
tests/unit/test_filesystem_read_many.py
Normal file
69
tests/unit/test_filesystem_read_many.py
Normal 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()
|
||||||
@@ -108,7 +108,7 @@ class TestToolRegistry:
|
|||||||
registry = create_default_registry(workspace, config)
|
registry = create_default_registry(workspace, config)
|
||||||
names = set(registry.get_all().keys())
|
names = set(registry.get_all().keys())
|
||||||
assert names == {
|
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",
|
"write_file", "make_dir", "delete_file",
|
||||||
"str_replace", "patch_apply",
|
"str_replace", "patch_apply",
|
||||||
"run_command",
|
"run_command",
|
||||||
@@ -118,7 +118,7 @@ class TestToolRegistry:
|
|||||||
def test_schema_export(self, workspace: Path, config: AppConfig) -> None:
|
def test_schema_export(self, workspace: Path, config: AppConfig) -> None:
|
||||||
registry = create_default_registry(workspace, config)
|
registry = create_default_registry(workspace, config)
|
||||||
schemas = registry.get_openai_tools_schema()
|
schemas = registry.get_openai_tools_schema()
|
||||||
assert len(schemas) == 11
|
assert len(schemas) == 12
|
||||||
assert all(s["type"] == "function" for s in schemas)
|
assert all(s["type"] == "function" for s in schemas)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user