diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 654a2c1..527b933 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,12 @@ name: CI on: push: - branches: ["main", "master"] + branches: ["main"] pull_request: - branches: ["main", "master"] + branches: ["main"] + +permissions: + contents: read jobs: test-lint-typecheck: @@ -12,10 +15,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" cache: "pip" @@ -23,19 +26,16 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install ruff black mypy + pip install -r requirements-dev.txt - - name: Ruff (lint + auto-fix) - run: | - ruff check . --fix - ruff check . + - name: Ruff + run: ruff check . - name: Black (format check) run: black --check . - - name: MyPy (type check, non-blocking for now) - run: mypy . || true + - name: MyPy + run: mypy . - name: Pytest run: pytest -v --tb=short diff --git a/agent.py b/agent.py index a73d31b..5499032 100644 --- a/agent.py +++ b/agent.py @@ -16,27 +16,31 @@ import random import time -from typing import Annotated, TypedDict, Iterator +from typing import Annotated, TypedDict from dotenv import load_dotenv from langchain_anthropic import ChatAnthropic -from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage -from langgraph.graph import StateGraph, END +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langgraph.graph import END, StateGraph from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode from config import config from context import build_system_prompt -from tools import ALL_TOOLS -from cost_tracker import track_call, init_tracker, register_exit_hook +from cost_tracker import init_tracker, register_exit_hook, track_call from exceptions import ConfigError, RetryExhaustedError from logger import get_logger from metrics import record_request from rate_limiter import acquire_or_raise from task import ( - TaskType, TaskStatus, - create_task, complete_task, fail_task, get_duration, + TaskStatus, + TaskType, + complete_task, + create_task, + fail_task, + get_duration, ) +from tools import ALL_TOOLS load_dotenv() log = get_logger("agent") @@ -221,7 +225,7 @@ def chat_stream(self, user_input, history=None): log.debug("Tool call detected in stream, switching to graph") response = self.chat(user_input, history) if collected_text and response.startswith(collected_text): - yield response[len(collected_text):] + yield response[len(collected_text) :] else: yield response success = True @@ -264,7 +268,8 @@ def _extract_text(messages): if isinstance(msg, AIMessage) and msg.content: if isinstance(msg.content, list): parts = [ - p["text"] for p in msg.content + p["text"] + for p in msg.content if isinstance(p, dict) and p.get("type") == "text" ] if parts: @@ -289,7 +294,9 @@ def get_task_summary(self): if not self.task_history: return "No tasks have run yet." - completed = sum(1 for t in self.task_history if t.status == TaskStatus.COMPLETED) + completed = sum( + 1 for t in self.task_history if t.status == TaskStatus.COMPLETED + ) failed = sum(1 for t in self.task_history if t.status == TaskStatus.FAILED) total = len(self.task_history) diff --git a/config.py b/config.py index 3e64e6c..2a3f688 100644 --- a/config.py +++ b/config.py @@ -5,6 +5,7 @@ import os from dataclasses import dataclass, field + from dotenv import load_dotenv load_dotenv() @@ -34,68 +35,155 @@ def _env_float(name: str, default: float) -> float: @dataclass(frozen=True) class ModelConfig: """LLM model settings.""" - name: str = field(default_factory=lambda: os.getenv("DEVMIND_MODEL", "claude-sonnet-4-6")) - max_tokens: int = field(default_factory=lambda: _env_int("DEVMIND_MAX_TOKENS", 4096)) + + name: str = field( + default_factory=lambda: os.getenv("DEVMIND_MODEL", "claude-sonnet-4-6") + ) + max_tokens: int = field( + default_factory=lambda: _env_int("DEVMIND_MAX_TOKENS", 4096) + ) streaming: bool = True @dataclass(frozen=True) class RetryConfig: """Retry logic settings with exponential backoff + jitter.""" + max_retries: int = field(default_factory=lambda: _env_int("DEVMIND_MAX_RETRIES", 4)) - base_delay: float = field(default_factory=lambda: _env_float("DEVMIND_RETRY_BASE_DELAY", 1.0)) - max_delay: float = field(default_factory=lambda: _env_float("DEVMIND_RETRY_MAX_DELAY", 30.0)) + base_delay: float = field( + default_factory=lambda: _env_float("DEVMIND_RETRY_BASE_DELAY", 1.0) + ) + max_delay: float = field( + default_factory=lambda: _env_float("DEVMIND_RETRY_MAX_DELAY", 30.0) + ) backoff_factor: float = 2.0 jitter: float = 0.25 retryable_codes: tuple = ( - "529", "overloaded", "rate_limit", - "timeout", "connection", "502", "503", "504", - "ECONNRESET", "Temporary failure", + "529", + "overloaded", + "rate_limit", + "timeout", + "connection", + "502", + "503", + "504", + "ECONNRESET", + "Temporary failure", ) @dataclass(frozen=True) class RateLimitConfig: """Client-side rate limiting (token bucket).""" + enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_RATE_LIMIT", True)) - requests_per_minute: int = field(default_factory=lambda: _env_int("DEVMIND_RPM", 50)) + requests_per_minute: int = field( + default_factory=lambda: _env_int("DEVMIND_RPM", 50) + ) burst: int = field(default_factory=lambda: _env_int("DEVMIND_BURST", 10)) @dataclass(frozen=True) class ToolConfig: """Tool execution settings.""" - bash_timeout: int = field(default_factory=lambda: _env_int("DEVMIND_BASH_TIMEOUT", 30)) + + bash_timeout: int = field( + default_factory=lambda: _env_int("DEVMIND_BASH_TIMEOUT", 30) + ) file_read_max_lines: int = 200 file_write_max_bytes: int = 5 * 1024 * 1024 file_read_max_bytes: int = 2 * 1024 * 1024 grep_max_results: int = 20 grep_max_files: int = 5000 blocked_commands: tuple = ( - "rm -rf /", "rm -rf /*", "rm -rf ~", "rm -rf ~/", - "mkfs", "dd if=", "dd of=/dev/", - ":(){", "fork bomb", "chmod -R 777 /", - "wget ", "curl ", - "shutdown", "reboot", "halt", "poweroff", - "> /dev/sda", "mv / ", + "rm -rf /", + "rm -rf /*", + "rm -rf ~", + "rm -rf ~/", + "mkfs", + "dd if=", + "dd of=/dev/", + ":(){", + "fork bomb", + "chmod -R 777 /", + "wget ", + "curl ", + "shutdown", + "reboot", + "halt", + "poweroff", + "> /dev/sda", + "mv / ", + ) + text_extensions: frozenset = frozenset( + { + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".java", + ".c", + ".cpp", + ".h", + ".cs", + ".go", + ".rs", + ".rb", + ".php", + ".swift", + ".kt", + ".scala", + ".html", + ".css", + ".scss", + ".less", + ".xml", + ".json", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".env", + ".sh", + ".bash", + ".zsh", + ".md", + ".txt", + ".rst", + ".csv", + ".sql", + ".r", + ".R", + ".lua", + ".dockerfile", + ".makefile", + ".gitignore", + ".editorconfig", + } + ) + skip_dirs: frozenset = frozenset( + { + ".git", + "__pycache__", + "node_modules", + ".venv", + "venv", + ".mypy_cache", + ".pytest_cache", + "dist", + "build", + ".tox", + } ) - text_extensions: frozenset = frozenset({ - ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h", - ".cs", ".go", ".rs", ".rb", ".php", ".swift", ".kt", ".scala", - ".html", ".css", ".scss", ".less", ".xml", ".json", ".yaml", ".yml", - ".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", - ".md", ".txt", ".rst", ".csv", ".sql", ".r", ".R", ".lua", - ".dockerfile", ".makefile", ".gitignore", ".editorconfig", - }) - skip_dirs: frozenset = frozenset({ - ".git", "__pycache__", "node_modules", ".venv", "venv", - ".mypy_cache", ".pytest_cache", "dist", "build", ".tox", - }) @dataclass(frozen=True) class HistoryConfig: """Conversation history settings.""" + max_messages: int = 20 summary_threshold: int = 16 @@ -103,6 +191,7 @@ class HistoryConfig: @dataclass(frozen=True) class PersistenceConfig: """Conversation save/load settings.""" + enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_PERSIST", True)) sessions_dir: str = field( default_factory=lambda: os.getenv( @@ -116,6 +205,7 @@ class PersistenceConfig: @dataclass(frozen=True) class MetricsConfig: """Performance metrics tracking.""" + enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_METRICS", True)) persist_on_exit: bool = True @@ -123,6 +213,7 @@ class MetricsConfig: @dataclass(frozen=True) class PluginConfig: """Plugin/extension system settings.""" + enabled: bool = field(default_factory=lambda: _env_bool("DEVMIND_PLUGINS", True)) plugins_dir: str = field( default_factory=lambda: os.getenv( @@ -135,6 +226,7 @@ class PluginConfig: @dataclass(frozen=True) class AppConfig: """Master application configuration.""" + model: ModelConfig = field(default_factory=ModelConfig) retry: RetryConfig = field(default_factory=RetryConfig) rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig) diff --git a/context.py b/context.py index 58b9136..748bbbc 100644 --- a/context.py +++ b/context.py @@ -13,7 +13,6 @@ import subprocess from datetime import datetime from pathlib import Path -from typing import Optional from logger import log @@ -37,7 +36,7 @@ def _run_git(args: list[str]) -> str: return "" -def get_git_status() -> Optional[str]: +def get_git_status() -> str | None: """ Return a summary of the current git repository state. (Inspired by context.ts: getGitStatus) @@ -74,7 +73,7 @@ def get_git_status() -> Optional[str]: # CLAUDE.md — Project-specific instructions # (Inspired by context.ts: getClaudeMds pattern) # ============================================================ -def get_claude_md() -> Optional[str]: +def get_claude_md() -> str | None: """ Read CLAUDE.md if it exists. This file contains project-specific instructions for Claude. @@ -138,7 +137,10 @@ def build_system_prompt() -> str: "- bash_tool: Run terminal commands (git, python, etc.)", "- file_read_tool: Read file contents", "- file_write_tool: Create new files or fully overwrite existing ones", - "- file_edit_tool: Find and replace specific text in an existing file (bug fixes, code updates)", + ( + "- file_edit_tool: Find and replace specific text in an existing file " + "(bug fixes, code updates)" + ), "- grep_tool: Search for patterns across the codebase", "- list_files_tool: List files in a directory", "", diff --git a/cost_tracker.py b/cost_tracker.py index 3ef30ac..013ad47 100644 --- a/cost_tracker.py +++ b/cost_tracker.py @@ -10,26 +10,24 @@ - Multi-model price support """ -import json import atexit +import json +from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path -from dataclasses import dataclass, field, asdict -from typing import Optional from logger import log - # ============================================================ # Claude model prices (per 1 million tokens) # ============================================================ MODEL_PRICES: dict[str, dict[str, float]] = { - "claude-opus-4": {"input": 15.0, "output": 75.0}, - "claude-sonnet-4": {"input": 3.0, "output": 15.0}, - "claude-sonnet-4-6": {"input": 3.0, "output": 15.0}, - "claude-haiku-4": {"input": 0.80, "output": 4.0}, - "claude-haiku-3-5": {"input": 0.25, "output": 1.25}, - "default": {"input": 3.0, "output": 15.0}, + "claude-opus-4": {"input": 15.0, "output": 75.0}, + "claude-sonnet-4": {"input": 3.0, "output": 15.0}, + "claude-sonnet-4-6": {"input": 3.0, "output": 15.0}, + "claude-haiku-4": {"input": 0.80, "output": 4.0}, + "claude-haiku-3-5": {"input": 0.25, "output": 1.25}, + "default": {"input": 3.0, "output": 15.0}, } @@ -39,10 +37,9 @@ @dataclass class SessionCosts: """Complete cost information for one session.""" + session_id: str - start_time: str = field( - default_factory=lambda: datetime.now().isoformat() - ) + start_time: str = field(default_factory=lambda: datetime.now().isoformat()) model: str = "claude-sonnet-4-6-20250929" total_input_tokens: int = 0 total_output_tokens: int = 0 @@ -54,7 +51,7 @@ class SessionCosts: # ============================================================ # Global session tracker # ============================================================ -_session: Optional[SessionCosts] = None +_session: SessionCosts | None = None def init_tracker(model: str = "claude-sonnet-4-6-20250929") -> SessionCosts: @@ -114,7 +111,7 @@ def calculate_cost(input_tokens: int, output_tokens: int, model: str) -> float: def track_call( input_tokens: int, output_tokens: int, - model: Optional[str] = None, + model: str | None = None, ) -> float: """ Record the cost of one API call. @@ -137,13 +134,15 @@ def track_call( session.total_calls += 1 session.total_cost_usd += cost - session.calls_log.append({ - "call_num": session.total_calls, - "time": datetime.now().strftime("%H:%M:%S"), - "input": input_tokens, - "output": output_tokens, - "cost_usd": cost, - }) + session.calls_log.append( + { + "call_num": session.total_calls, + "time": datetime.now().strftime("%H:%M:%S"), + "input": input_tokens, + "output": output_tokens, + "cost_usd": cost, + } + ) log.debug( f"API call #{session.total_calls}: " @@ -211,6 +210,7 @@ def save_session_costs() -> None: # ============================================================ def register_exit_hook() -> None: """Print cost summary and save to disk when the app exits.""" + def on_exit(): session = get_session() if session.total_calls > 0: diff --git a/exceptions.py b/exceptions.py index 35f6c60..5ff60b7 100644 --- a/exceptions.py +++ b/exceptions.py @@ -6,11 +6,13 @@ class DevMindError(Exception): """Base exception for all DevMind errors.""" + pass class ConfigError(DevMindError): """Configuration errors — missing API key, invalid settings.""" + pass @@ -24,6 +26,7 @@ def __init__(self, tool_name: str, message: str): class AgentError(DevMindError): """Errors during agent / LLM interaction.""" + pass @@ -34,8 +37,7 @@ def __init__(self, attempts: int, last_error: Exception): self.attempts = attempts self.last_error = last_error super().__init__( - f"DevMind failed after {attempts} attempt(s). " - f"Last error: {last_error}" + f"DevMind failed after {attempts} attempt(s). " f"Last error: {last_error}" ) @@ -44,9 +46,7 @@ class RateLimitError(AgentError): def __init__(self, wait_seconds: float): self.wait_seconds = wait_seconds - super().__init__( - f"Local rate limit exceeded. Retry after {wait_seconds:.1f}s." - ) + super().__init__(f"Local rate limit exceeded. Retry after {wait_seconds:.1f}s.") class SecurityError(ToolError): @@ -66,11 +66,13 @@ def __init__(self, filepath: str): class ValidationError(DevMindError): """Input validation failed (bad arguments, invalid format).""" + pass class PersistenceError(DevMindError): """Conversation save/load errors (corrupted file, permission denied).""" + pass diff --git a/logger.py b/logger.py index 2607338..7b1509d 100644 --- a/logger.py +++ b/logger.py @@ -15,19 +15,24 @@ import os import re import sys -from pathlib import Path from datetime import datetime - +from pathlib import Path # Secret masking patterns _SECRET_PATTERNS = [ (re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}"), "sk-ant-***REDACTED***"), (re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), "sk-***REDACTED***"), (re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{16,}"), "Bearer ***REDACTED***"), - (re.compile(r"(?i)(api[_\-]?key|apikey|token|password|secret)\s*[:=]\s*['\"]?[^\s'\"&]{8,}"), - r"\1=***REDACTED***"), - (re.compile(r"(?i)authorization\s*[:=]\s*['\"]?[^\s'\"&]+"), - "authorization=***REDACTED***"), + ( + re.compile( + r"(?i)(api[_\-]?key|apikey|token|password|secret)\s*[:=]\s*['\"]?[^\s'\"&]{8,}" + ), + r"\1=***REDACTED***", + ), + ( + re.compile(r"(?i)authorization\s*[:=]\s*['\"]?[^\s'\"&]+"), + "authorization=***REDACTED***", + ), ] @@ -68,7 +73,9 @@ class JsonFormatter(logging.Formatter): def format(self, record): payload = { - "time": datetime.fromtimestamp(record.created).isoformat(timespec="milliseconds"), + "time": datetime.fromtimestamp(record.created).isoformat( + timespec="milliseconds" + ), "level": record.levelname, "logger": record.name, "message": record.getMessage(), @@ -116,10 +123,12 @@ def setup_logger(name="devmind", level=None, log_to_file=True, json_logs=None): if json_logs: file_handler.setFormatter(JsonFormatter()) else: - file_handler.setFormatter(logging.Formatter( - "%(asctime)s | %(name)-18s | %(levelname)-8s | %(message)s", - datefmt="%H:%M:%S", - )) + file_handler.setFormatter( + logging.Formatter( + "%(asctime)s | %(name)-18s | %(levelname)-8s | %(message)s", + datefmt="%H:%M:%S", + ) + ) file_handler.addFilter(SecretMaskingFilter()) logger.addHandler(file_handler) except Exception: diff --git a/main.py b/main.py index 78e2d27..23148cb 100644 --- a/main.py +++ b/main.py @@ -15,20 +15,20 @@ import os import sys -from pathlib import Path try: from rich.console import Console - from rich.panel import Panel - from rich.text import Text from rich.markdown import Markdown + from rich.panel import Panel from rich.table import Table + from rich.text import Text except ImportError: print("Please install the Rich library: pip install rich") sys.exit(1) try: from dotenv import load_dotenv + load_dotenv() except ImportError: print("Please install python-dotenv: pip install python-dotenv") @@ -48,12 +48,16 @@ def show_banner(): banner.append(" AI Coding Assistant", style="dim") console.print() - console.print(Panel( - banner, - subtitle="[dim]Inspired by Claude Code . Python + LangGraph . Streaming[/dim]", - border_style="blue", - padding=(0, 2), - )) + console.print( + Panel( + banner, + subtitle=( + "[dim]Inspired by Claude Code . Python + LangGraph . " "Streaming[/dim]" + ), + border_style="blue", + padding=(0, 2), + ) + ) console.print( "[dim]Commands: [bold]/help[/bold] . [bold]/cost[/bold] . " "[bold]/metrics[/bold] . [bold]/save[/bold] . [bold]/load[/bold] . " @@ -103,8 +107,8 @@ def smart_truncate_history(history, max_messages=None, summary_threshold=None): if len(history) <= threshold: return history - old_messages = history[:len(history) - max_msgs + 2] - recent_messages = history[len(history) - max_msgs + 2:] + old_messages = history[: len(history) - max_msgs + 2] + recent_messages = history[len(history) - max_msgs + 2 :] summary_parts = [] for msg in old_messages: @@ -124,35 +128,44 @@ def smart_truncate_history(history, max_messages=None, summary_threshold=None): ] log.debug( - f"History truncated: {len(history)} -> {len(summarized) + len(recent_messages)} messages" + "History truncated: " + f"{len(history)} -> {len(summarized) + len(recent_messages)} messages" ) return summarized + recent_messages def _cmd_cost(): from cost_tracker import format_cost_summary - console.print(Panel( - format_cost_summary(), - title="[dim]Session Cost[/dim]", - border_style="dim", - )) + + console.print( + Panel( + format_cost_summary(), + title="[dim]Session Cost[/dim]", + border_style="dim", + ) + ) def _cmd_metrics(): from metrics import format_metrics_summary - console.print(Panel( - format_metrics_summary(), - title="[dim]Performance Metrics[/dim]", - border_style="dim", - )) + + console.print( + Panel( + format_metrics_summary(), + title="[dim]Performance Metrics[/dim]", + border_style="dim", + ) + ) def _cmd_tasks(agent): - console.print(Panel( - agent.get_task_summary(), - title="[dim]Task History[/dim]", - border_style="dim", - )) + console.print( + Panel( + agent.get_task_summary(), + title="[dim]Task History[/dim]", + border_style="dim", + ) + ) def _cmd_history(history): @@ -172,6 +185,7 @@ def _cmd_save(args, history, agent): return try: from persistence import save_session + path = save_session(args, history, agent.model_name) console.print(f"[green]Saved:[/green] {path}") except Exception as e: @@ -184,6 +198,7 @@ def _cmd_load(args, history): return history try: from persistence import load_session + session = load_session(args) console.print( f"[green]Loaded[/green] '{session.session_id}' " @@ -198,6 +213,7 @@ def _cmd_load(args, history): def _cmd_sessions(): try: from persistence import list_sessions + sessions = list_sessions() except Exception as e: console.print(f"[red]Failed to list sessions:[/red] {e}") @@ -223,6 +239,7 @@ def _cmd_delete(args): return try: from persistence import delete_session + if delete_session(args): console.print(f"[green]Deleted session '{args}'[/green]") else: @@ -275,6 +292,7 @@ def handle_slash_command(cmd, history, agent): try: os.chdir(new_dir) from context import build_system_prompt + agent.system_prompt = build_system_prompt() console.print(f"[dim]Working directory: {os.getcwd()}[/dim]") log.info(f"Working directory changed: {os.getcwd()}") @@ -331,6 +349,7 @@ def _maybe_autosave(history, agent, turn): return try: from persistence import save_session + save_session("autosave", history, agent.model_name, metadata={"auto": True}) log.debug(f"Autosaved at turn {turn}") except Exception as e: @@ -343,6 +362,7 @@ def run_repl(): try: from agent import DevMindAgent + agent = DevMindAgent() console.print(" [green]Ready![/green]\n") except Exception as e: diff --git a/metrics.py b/metrics.py index a2ca9b9..17b74ea 100644 --- a/metrics.py +++ b/metrics.py @@ -18,10 +18,9 @@ import json import threading import time -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path -from typing import Optional from config import config from logger import get_logger @@ -35,6 +34,7 @@ @dataclass class LatencySeries: """Rolling latency samples with percentile calculations.""" + samples: list[float] = field(default_factory=list) max_samples: int = 500 # keep memory bounded @@ -53,11 +53,16 @@ def percentile(self, p: float) -> float: return round(ordered[idx], 4) @property - def p50(self) -> float: return self.percentile(50) + def p50(self) -> float: + return self.percentile(50) + @property - def p95(self) -> float: return self.percentile(95) + def p95(self) -> float: + return self.percentile(95) + @property - def p99(self) -> float: return self.percentile(99) + def p99(self) -> float: + return self.percentile(99) @property def mean(self) -> float: @@ -71,6 +76,7 @@ def count(self) -> int: @dataclass class ToolMetrics: """Per-tool invocation metrics.""" + invocations: int = 0 errors: int = 0 total_time_seconds: float = 0.0 @@ -81,12 +87,17 @@ def error_rate(self) -> float: @property def avg_time_seconds(self) -> float: - return round(self.total_time_seconds / self.invocations, 4) if self.invocations else 0.0 + return ( + round(self.total_time_seconds / self.invocations, 4) + if self.invocations + else 0.0 + ) @dataclass class MetricsSnapshot: """A serializable view of all current metrics.""" + session_id: str started_at: str uptime_seconds: float @@ -160,15 +171,21 @@ def snapshot(self) -> MetricsSnapshot: requests_succeeded=self.requests_succeeded, requests_failed=self.requests_failed, retries_total=self.retries_total, - success_rate=round(self.requests_succeeded / total, 4) if total else 0.0, + success_rate=( + round(self.requests_succeeded / total, 4) if total else 0.0 + ), latency_p50=self.latencies.p50, latency_p95=self.latencies.p95, latency_p99=self.latencies.p99, latency_mean=self.latencies.mean, - tools={name: asdict(m) | { - "error_rate": m.error_rate, - "avg_time_seconds": m.avg_time_seconds, - } for name, m in self.tools.items()}, + tools={ + name: asdict(m) + | { + "error_rate": m.error_rate, + "avg_time_seconds": m.avg_time_seconds, + } + for name, m in self.tools.items() + }, ) def format_summary(self) -> str: @@ -205,7 +222,7 @@ def format_summary(self) -> str: # -------------------------------------------------------- # Persistence # -------------------------------------------------------- - def persist(self) -> Optional[Path]: + def persist(self) -> Path | None: """Write a JSON snapshot to disk. Returns the file path or None.""" if self.requests_total == 0: return None diff --git a/persistence.py b/persistence.py index 3888547..b0a33c6 100644 --- a/persistence.py +++ b/persistence.py @@ -24,10 +24,9 @@ import json import os import re -from dataclasses import dataclass, asdict, field +from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path -from typing import Optional from config import config from exceptions import PersistenceError, ValidationError @@ -83,8 +82,8 @@ def _session_path(name: str) -> Path: target = (base / f"{name}.json").resolve() try: target.relative_to(base) - except ValueError: - raise ValidationError("Session path escapes the sessions directory.") + except ValueError as exc: + raise ValidationError("Session path escapes the sessions directory.") from exc return target @@ -115,7 +114,7 @@ def save_session( name: str, history: list[dict], model: str, - metadata: Optional[dict] = None, + metadata: dict | None = None, ) -> Path: """ Save a conversation session to disk. @@ -224,12 +223,14 @@ def list_sessions() -> list[dict]: for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True): try: data = json.loads(f.read_text(encoding="utf-8")) - out.append({ - "name": f.stem, - "updated_at": data.get("updated_at", ""), - "messages": len(data.get("history", [])), - "model": data.get("model", ""), - }) + out.append( + { + "name": f.stem, + "updated_at": data.get("updated_at", ""), + "messages": len(data.get("history", [])), + "model": data.get("model", ""), + } + ) except Exception as e: log.debug(f"Skipping unreadable session file {f}: {e}") return out @@ -248,10 +249,15 @@ def delete_session(name: str) -> bool: if __name__ == "__main__": # Smoke test import tempfile + with tempfile.TemporaryDirectory() as tmp: os.environ["DEVMIND_SESSIONS_DIR"] = tmp # Reload config for the override to take effect - import importlib, config as _cfg_mod + import importlib + + import config as _cfg_mod + importlib.reload(_cfg_mod) from config import config as reloaded + print("Sessions dir:", reloaded.persistence.sessions_dir) diff --git a/plugins.py b/plugins.py index 364bef0..14893a2 100644 --- a/plugins.py +++ b/plugins.py @@ -154,6 +154,7 @@ def collect_plugin_tools(plugins_dir: str | Path) -> list[Any]: if __name__ == "__main__": from config import config + tools = collect_plugin_tools(config.plugins.plugins_dir) print(f"Loaded {len(tools)} plugin tools") for t in tools: diff --git a/rate_limiter.py b/rate_limiter.py index 3517bc2..128de12 100644 --- a/rate_limiter.py +++ b/rate_limiter.py @@ -32,6 +32,7 @@ class TokenBucket: rate_per_sec: Sustained refill rate in tokens per second. capacity: Maximum burst size the bucket can hold. """ + rate_per_sec: float capacity: float @@ -129,9 +130,7 @@ def get_limiter() -> TokenBucket | None: rate_per_sec=rpm / 60.0, capacity=float(burst), ) - log.debug( - f"Global rate limiter: {rpm} req/min, burst={burst}" - ) + log.debug(f"Global rate limiter: {rpm} req/min, burst={burst}") return _global_limiter diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b813071 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +black==26.5.1 +mypy==2.2.0 +ruff==0.15.21 diff --git a/security.py b/security.py index 8ae697a..4c2745c 100644 --- a/security.py +++ b/security.py @@ -16,7 +16,6 @@ import re import shlex from pathlib import Path -from typing import Optional from config import config from logger import get_logger @@ -30,10 +29,10 @@ def validate_path( filepath: str, *, - base_dir: Optional[Path] = None, + base_dir: Path | None = None, must_exist: bool = False, allow_symlinks: bool = False, -) -> tuple[bool, str, Optional[Path]]: +) -> tuple[bool, str, Path | None]: """ Validate a filesystem path. @@ -68,7 +67,10 @@ def validate_path( log.warning(f"Path traversal attempt blocked: {filepath!r}") return ( False, - "Security: Only files within the current working directory can be accessed.", + ( + "Security: Only files within the current working directory " + "can be accessed." + ), None, ) @@ -125,7 +127,13 @@ def validate_command(command: str) -> tuple[bool, str]: for blocked in config.tool.blocked_commands: if blocked.strip() and blocked.lower() in lower: log.warning(f"Blocked command pattern '{blocked}': {stripped[:80]}") - return False, f"This command has been blocked for security reasons ({blocked.strip()})." + return ( + False, + ( + "This command has been blocked for security reasons " + f"({blocked.strip()})." + ), + ) # Block sudo and su outright try: diff --git a/task.py b/task.py index 9b2b51e..5ea4d97 100644 --- a/task.py +++ b/task.py @@ -7,20 +7,19 @@ import secrets import string import time -from enum import Enum from dataclasses import dataclass, field -from typing import Optional +from enum import Enum # ============================================================ # Task Types — the kinds of work the agent can do # ============================================================ class TaskType(Enum): - BASH = "bash" # Running a terminal command - FILE_READ = "file_read" # Reading a file - FILE_WRITE = "file_write" # Writing a file - WEB_SEARCH = "web_search" # Searching the internet - AGENT = "agent" # Querying Claude + BASH = "bash" # Running a terminal command + FILE_READ = "file_read" # Reading a file + FILE_WRITE = "file_write" # Writing a file + WEB_SEARCH = "web_search" # Searching the internet + AGENT = "agent" # Querying Claude # ============================================================ @@ -28,15 +27,18 @@ class TaskType(Enum): # (Inspired by Task.ts: TaskStatus) # ============================================================ class TaskStatus(Enum): - PENDING = "pending" # Not started yet - RUNNING = "running" # Currently in progress - COMPLETED = "completed" # Finished successfully - FAILED = "failed" # Failed with an error - KILLED = "killed" # Terminated mid-execution + PENDING = "pending" # Not started yet + RUNNING = "running" # Currently in progress + COMPLETED = "completed" # Finished successfully + FAILED = "failed" # Failed with an error + KILLED = "killed" # Terminated mid-execution def is_terminal_status(status: TaskStatus) -> bool: - """Return True if the task can no longer change state. (Task.ts: isTerminalTaskStatus)""" + """Return whether the task can no longer change state. + + Mirrors Task.ts: isTerminalTaskStatus. + """ return status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.KILLED) @@ -59,10 +61,7 @@ def is_terminal_status(status: TaskStatus) -> bool: def generate_task_id(task_type: TaskType) -> str: """Generate a secure random ID like 'b4f7k2m9'.""" prefix = TASK_PREFIXES.get(task_type, "x") - random_part = "".join( - ALPHABET[b % len(ALPHABET)] - for b in secrets.token_bytes(8) - ) + random_part = "".join(ALPHABET[b % len(ALPHABET)] for b in secrets.token_bytes(8)) return prefix + random_part @@ -77,9 +76,9 @@ class TaskState: description: str status: TaskStatus = TaskStatus.PENDING start_time: float = field(default_factory=time.time) - end_time: Optional[float] = None - result: Optional[str] = None - error: Optional[str] = None + end_time: float | None = None + result: str | None = None + error: str | None = None def create_task(task_type: TaskType, description: str) -> TaskState: @@ -107,7 +106,7 @@ def fail_task(task: TaskState, error: str) -> TaskState: return task -def get_duration(task: TaskState) -> Optional[float]: +def get_duration(task: TaskState) -> float | None: """Return how many seconds the task took, or None if still running.""" if task.end_time and task.start_time: return round(task.end_time - task.start_time, 2) diff --git a/tests/test_config.py b/tests/test_config.py index 5af0a89..d802a8a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,11 +3,12 @@ """ import sys +from dataclasses import FrozenInstanceError from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) -from config import AppConfig, ModelConfig, RetryConfig, ToolConfig, HistoryConfig +from config import AppConfig, HistoryConfig, ModelConfig, RetryConfig, ToolConfig class TestModelConfig: @@ -82,13 +83,14 @@ def test_frozen_config(self): cfg = ModelConfig() try: cfg.name = "something-else" - assert False, "Should have raised FrozenInstanceError" - except Exception: - pass # Expected — frozen dataclass + except FrozenInstanceError: + return + raise AssertionError("Should have raised FrozenInstanceError") if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_context.py b/tests/test_context.py index b0fe4b9..257a4b3 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -86,6 +86,7 @@ def test_empty_claude_md_returns_none(self, tmp_path, monkeypatch): if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_cost_tracker.py b/tests/test_cost_tracker.py index cfaba24..0d0d144 100644 --- a/tests/test_cost_tracker.py +++ b/tests/test_cost_tracker.py @@ -8,8 +8,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from cost_tracker import ( - init_tracker, track_call, calculate_cost, - format_cost_summary, get_session, MODEL_PRICES, + MODEL_PRICES, + calculate_cost, + format_cost_summary, + get_session, + init_tracker, + track_call, ) @@ -108,10 +112,10 @@ def test_summary_shows_totals(self): track_call(1000, 500, "claude-sonnet-4-6") summary = format_cost_summary() - assert "1" in summary # total calls - assert "1,000" in summary # input tokens (formatted) - assert "500" in summary # output tokens - assert "$" in summary # cost in USD + assert "1" in summary # total calls + assert "1,000" in summary # input tokens (formatted) + assert "500" in summary # output tokens + assert "$" in summary # cost in USD def test_summary_shows_model(self): """Summary must include the model name""" @@ -123,6 +127,7 @@ def test_summary_shows_model(self): if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index eac4cae..c5fccd6 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -8,8 +8,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from exceptions import ( - DevMindError, ConfigError, ToolError, - AgentError, RetryExhaustedError, SecurityError, + AgentError, + ConfigError, + DevMindError, + RetryExhaustedError, + SecurityError, + ToolError, ) @@ -67,6 +71,7 @@ def test_security_prefix(self): if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_main.py b/tests/test_main.py index 5e01b50..9032a98 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) -from main import smart_truncate_history, show_banner, show_help +from main import show_banner, show_help, smart_truncate_history class TestSmartTruncateHistory: @@ -66,21 +66,16 @@ def test_threshold_boundary(self): class TestUIFunctions: def test_show_banner_no_error(self): """Banner should display without raising an error""" - try: - show_banner() - except Exception as e: - assert False, f"show_banner() raised an exception: {e}" + show_banner() def test_show_help_no_error(self): """Help panel should display without raising an error""" - try: - show_help() - except Exception as e: - assert False, f"show_help() raised an exception: {e}" + show_help() if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 118794d..18434d7 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -8,8 +8,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from metrics import ( - LatencySeries, MetricsRegistry, reset_registry, get_registry, - record_request, record_tool, format_metrics_summary, + LatencySeries, + MetricsRegistry, + format_metrics_summary, + get_registry, + record_request, + record_tool, + reset_registry, ) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 3c562a9..2a124d6 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -10,10 +10,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) import persistence +from exceptions import PersistenceError, ValidationError from persistence import ( - save_session, load_session, list_sessions, delete_session, + delete_session, + list_sessions, + load_session, + save_session, ) -from exceptions import ValidationError, PersistenceError @pytest.fixture diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 070244c..c7ff5b9 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -5,12 +5,9 @@ import sys from pathlib import Path -import pytest - sys.path.insert(0, str(Path(__file__).parent.parent)) -from plugins import load_plugins, collect_plugin_tools - +from plugins import collect_plugin_tools, load_plugins EXAMPLE_PLUGIN = ''' from langchain_core.tools import tool @@ -27,9 +24,9 @@ def second_plugin_tool(x: int) -> int: ''' -BROKEN_PLUGIN = ''' +BROKEN_PLUGIN = """ this is not valid python !@#$ -''' +""" class TestPluginLoading: diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index e213edc..38efe06 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -6,12 +6,9 @@ import time from pathlib import Path -import pytest - sys.path.insert(0, str(Path(__file__).parent.parent)) from rate_limiter import TokenBucket, acquire_or_raise, reset_limiter -from exceptions import RateLimitError class TestTokenBucket: diff --git a/tests/test_task.py b/tests/test_task.py index 8108136..debbd67 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -9,9 +9,14 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from task import ( - TaskType, TaskStatus, - create_task, complete_task, fail_task, get_duration, - generate_task_id, is_terminal_status, TaskState, + TaskState, + TaskStatus, + TaskType, + complete_task, + create_task, + fail_task, + get_duration, + is_terminal_status, ) @@ -139,6 +144,7 @@ def test_all_statuses_exist(self): if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tests/test_tools.py b/tests/test_tools.py index e0bc5ca..5848a6d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -9,14 +9,14 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from tools import ( + ALL_TOOLS, + _validate_path, bash_tool, + file_edit_tool, file_read_tool, file_write_tool, - file_edit_tool, grep_tool, list_files_tool, - _validate_path, - ALL_TOOLS, ) @@ -50,10 +50,10 @@ def test_simple_command(self): result = bash_tool.invoke({"command": "echo Hello DevMind"}) assert "Hello DevMind" in result - def test_python_version(self): - """Python version command should return version string""" - result = bash_tool.invoke({"command": "python3 --version"}) - assert "Python" in result + def test_shell_version(self): + """The selected Bash executable should be usable.""" + result = bash_tool.invoke({"command": 'echo "$BASH_VERSION"'}) + assert result and result[0].isdigit() def test_error_command(self): """An invalid command should return an error""" @@ -131,10 +131,9 @@ def test_write_new_file(self, tmp_path, monkeypatch): """A new file should be created correctly""" monkeypatch.chdir(tmp_path) new_file = tmp_path / "new_file.py" - result = file_write_tool.invoke({ - "filepath": str(new_file), - "content": "print('DevMind rocks!')" - }) + result = file_write_tool.invoke( + {"filepath": str(new_file), "content": "print('DevMind rocks!')"} + ) assert "written" in result.lower() assert new_file.read_text() == "print('DevMind rocks!')" @@ -144,20 +143,14 @@ def test_overwrite_existing(self, tmp_path, monkeypatch): existing = tmp_path / "existing.txt" existing.write_text("old content") - file_write_tool.invoke({ - "filepath": str(existing), - "content": "new content" - }) + file_write_tool.invoke({"filepath": str(existing), "content": "new content"}) assert existing.read_text() == "new content" def test_creates_parent_dirs(self, tmp_path, monkeypatch): """Missing parent directories should be created automatically""" monkeypatch.chdir(tmp_path) deep_file = tmp_path / "a" / "b" / "c" / "deep.py" - file_write_tool.invoke({ - "filepath": str(deep_file), - "content": "# deep file" - }) + file_write_tool.invoke({"filepath": str(deep_file), "content": "# deep file"}) assert deep_file.exists() @@ -171,11 +164,13 @@ def test_simple_edit(self, tmp_path, monkeypatch): f = tmp_path / "edit_me.py" f.write_text("def hello():\n print('hi')\n") - result = file_edit_tool.invoke({ - "filepath": str(f), - "old_text": "print('hi')", - "new_text": "print('Hello DevMind!')" - }) + result = file_edit_tool.invoke( + { + "filepath": str(f), + "old_text": "print('hi')", + "new_text": "print('Hello DevMind!')", + } + ) assert "edited" in result.lower() or "edit" in result.lower() assert "Hello DevMind!" in f.read_text() @@ -186,11 +181,13 @@ def test_edit_not_found(self, tmp_path, monkeypatch): f = tmp_path / "file.py" f.write_text("def foo(): pass\n") - result = file_edit_tool.invoke({ - "filepath": str(f), - "old_text": "this_does_not_exist", - "new_text": "replacement" - }) + result = file_edit_tool.invoke( + { + "filepath": str(f), + "old_text": "this_does_not_exist", + "new_text": "replacement", + } + ) assert "not found" in result.lower() def test_edit_ambiguous(self, tmp_path, monkeypatch): @@ -199,21 +196,21 @@ def test_edit_ambiguous(self, tmp_path, monkeypatch): f = tmp_path / "dup.py" f.write_text("x = 1\nx = 1\n") - result = file_edit_tool.invoke({ - "filepath": str(f), - "old_text": "x = 1", - "new_text": "x = 99" - }) + result = file_edit_tool.invoke( + {"filepath": str(f), "old_text": "x = 1", "new_text": "x = 99"} + ) assert "ambiguous" in result or "2" in result def test_edit_file_not_found(self, tmp_path, monkeypatch): """Should return an error for a non-existent file""" monkeypatch.chdir(tmp_path) - result = file_edit_tool.invoke({ - "filepath": str(tmp_path / "nope.py"), - "old_text": "anything", - "new_text": "whatever" - }) + result = file_edit_tool.invoke( + { + "filepath": str(tmp_path / "nope.py"), + "old_text": "anything", + "new_text": "whatever", + } + ) assert "not found" in result.lower() def test_same_old_new_text(self, tmp_path, monkeypatch): @@ -221,11 +218,9 @@ def test_same_old_new_text(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) f = tmp_path / "same.py" f.write_text("x = 1\n") - result = file_edit_tool.invoke({ - "filepath": str(f), - "old_text": "x = 1", - "new_text": "x = 1" - }) + result = file_edit_tool.invoke( + {"filepath": str(f), "old_text": "x = 1", "new_text": "x = 1"} + ) assert "identical" in result.lower() or "same" in result.lower() def test_multiline_edit(self, tmp_path, monkeypatch): @@ -235,11 +230,13 @@ def test_multiline_edit(self, tmp_path, monkeypatch): original = "def old_func():\n return 1\n\ndef other(): pass\n" f.write_text(original) - result = file_edit_tool.invoke({ - "filepath": str(f), - "old_text": "def old_func():\n return 1", - "new_text": "def new_func():\n return 42\n # updated!" - }) + result = file_edit_tool.invoke( + { + "filepath": str(f), + "old_text": "def old_func():\n return 1", + "new_text": "def new_func():\n return 42\n # updated!", + } + ) assert "edited" in result.lower() or "edit" in result.lower() content = f.read_text() @@ -267,7 +264,9 @@ def test_pattern_not_found(self, tmp_path, monkeypatch): f = tmp_path / "code.py" f.write_text("x = 1\n") - result = grep_tool.invoke({"pattern": "xyz_not_here", "directory": str(tmp_path)}) + result = grep_tool.invoke( + {"pattern": "xyz_not_here", "directory": str(tmp_path)} + ) assert "not found" in result.lower() def test_case_insensitive(self, tmp_path, monkeypatch): @@ -287,7 +286,7 @@ def test_empty_pattern_error(self): def test_skips_binary_files(self, tmp_path, monkeypatch): """Binary files should be excluded from search results""" monkeypatch.chdir(tmp_path) - (tmp_path / "image.png").write_bytes(b'\x89PNG\r\n\x1a\nhello') + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\nhello") (tmp_path / "code.py").write_text("hello world\n") result = grep_tool.invoke({"pattern": "hello", "directory": str(tmp_path)}) @@ -353,6 +352,7 @@ def test_tools_have_descriptions(self): if __name__ == "__main__": import subprocess + result = subprocess.run( ["python", "-m", "pytest", __file__, "-v", "--tb=short"], cwd=str(Path(__file__).parent.parent), diff --git a/tools.py b/tools.py index 25255a3..550ebbe 100644 --- a/tools.py +++ b/tools.py @@ -17,22 +17,40 @@ import functools import os +import shutil import subprocess import time +from collections.abc import Callable from pathlib import Path -from typing import Callable from langchain_core.tools import tool from config import config from logger import get_logger from metrics import record_tool -from security import validate_command, validate_path, validate_string from plugins import collect_plugin_tools +from security import validate_command, validate_path, validate_string log = get_logger("tools") +def _find_bash_executable() -> str: + """Return a real Bash executable, preferring Git Bash on Windows.""" + if os.name == "nt": + git_executable = shutil.which("git") + if git_executable: + git_root = Path(git_executable).resolve().parent.parent + for relative_path in ("bin/bash.exe", "usr/bin/bash.exe"): + candidate = git_root / relative_path + if candidate.is_file(): + return str(candidate) + + bash_executable = shutil.which("bash") + if bash_executable: + return bash_executable + raise FileNotFoundError("Bash was not found. Install Git Bash or Bash.") + + def _validate_path(filepath: str): """Backward-compatible wrapper for the old (is_valid, error, path) signature.""" return validate_path(filepath) @@ -40,6 +58,7 @@ def _validate_path(filepath: str): def _timed(tool_name: str): """Decorator that times a tool invocation and records it in metrics.""" + def deco(fn: Callable[..., str]) -> Callable[..., str]: @functools.wraps(fn) def wrapper(*args, **kwargs) -> str: @@ -48,9 +67,15 @@ def wrapper(*args, **kwargs) -> str: try: result = fn(*args, **kwargs) low = (result or "").lower() - if any(low.startswith(prefix) for prefix in ( - "error", "security:", "timeout", "permission denied", - )): + if any( + low.startswith(prefix) + for prefix in ( + "error", + "security:", + "timeout", + "permission denied", + ) + ): success = False return result except Exception as e: @@ -59,7 +84,9 @@ def wrapper(*args, **kwargs) -> str: return f"Error in {tool_name}: {e}" finally: record_tool(tool_name, time.monotonic() - start, success) + return wrapper + return deco @@ -84,7 +111,7 @@ def _bash_impl(command: str) -> str: try: result = subprocess.run( - ["bash", "-c", command], + [_find_bash_executable(), "-c", command], capture_output=True, text=True, timeout=config.tool.bash_timeout, @@ -129,6 +156,8 @@ def _file_read_impl(filepath: str) -> str: is_valid, error, path = validate_path(filepath) if not is_valid: return error + if path is None: + return "Error: Path validation did not return a resolved path." try: if not path.exists(): @@ -144,7 +173,7 @@ def _file_read_impl(filepath: str) -> str: f"(max {max_bytes}). Use grep_tool or read a slice." ) - with open(path, "r", encoding="utf-8", errors="ignore") as f: + with open(path, encoding="utf-8", errors="ignore") as f: lines = f.readlines() max_lines = config.tool.file_read_max_lines @@ -192,6 +221,8 @@ def _file_write_impl(filepath: str, content: str) -> str: is_valid, error, path = validate_path(filepath) if not is_valid: return error + if path is None: + return "Error: Path validation did not return a resolved path." try: path.parent.mkdir(parents=True, exist_ok=True) @@ -244,6 +275,8 @@ def _file_edit_impl(filepath: str, old_text: str, new_text: str) -> str: is_valid, error, path = validate_path(filepath) if not is_valid: return error + if path is None: + return "Error: Path validation did not return a resolved path." if old_text == new_text: return "old_text and new_text are identical — nothing to change." @@ -308,7 +341,7 @@ def _grep_impl(pattern: str, directory: str = ".") -> str: return "Pattern is empty! Please provide a search pattern." try: - results = [] + results: list[str] = [] search_dir = Path(directory) if not search_dir.exists(): @@ -335,7 +368,7 @@ def _grep_impl(pattern: str, directory: str = ".") -> str: files_scanned += 1 try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + with open(file_path, encoding="utf-8", errors="ignore") as f: for line_num, line in enumerate(f, 1): if pattern.lower() in line.lower(): rel_path = file_path.relative_to(search_dir)