From f4e85522981e4dcfa05a0e51aeeb04bdd76a554b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 10 Jan 2026 01:26:30 +0100 Subject: [PATCH] feat: add Explore agent with PydanticAI tool calling Implements the first Claude-like agent for codebase exploration: Core Features: - Explore agent with glob, grep, read, and bash tools - Native PydanticAI tool calling with Ollama/Mistral Nemo - Sanitized Ollama provider (fixes content:null issue) - REST API endpoints for agent execution Tool Infrastructure: - BaseTool abstract class with ToolResult dataclass - ReadFileTool, GlobFilesTool, GrepContentTool, BashReadOnlyTool - Path validation and sandboxing support CLI Client (separate package for future extraction): - webber-cli command with chat, explore, status commands - Communicates with Webber API backend - Rich console output with theming Configuration: - Dev server on port 8095 (production uses 8086) - Mistral Nemo optimizations (temp 0.3, tool_choice required) Tests: 24 tests covering tools and API endpoints Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 12 ++ chat.sh | 14 ++ cli/__init__.py | 7 + cli/client.py | 141 +++++++++++++ cli/main.py | 261 +++++++++++++++++++++++++ cli/theme.py | 75 +++++++ docs/mistral-instructions.md | 14 ++ pyproject.toml | 6 +- requirements.txt | 4 + src/cli/__init__.py | 3 + src/cli/commands/__init__.py | 6 + src/cli/commands/chat.py | 128 ++++++++++++ src/cli/commands/explore.py | 83 ++++++++ src/cli/commands/version.py | 26 +++ src/cli/main.py | 64 ++++++ src/cli/session/__init__.py | 7 + src/cli/session/context.py | 60 ++++++ src/cli/session/loop.py | 104 ++++++++++ src/cli/theme.py | 70 +++++++ src/cli/ui/__init__.py | 7 + src/cli/ui/console.py | 37 ++++ src/cli/ui/display.py | 84 ++++++++ src/domains/agents/__init__.py | 37 ++++ src/domains/agents/base.py | 167 ++++++++++++++++ src/domains/agents/explore/__init__.py | 16 ++ src/domains/agents/explore/agent.py | 127 ++++++++++++ src/domains/agents/explore/prompts.py | 98 ++++++++++ src/domains/agents/explore/tools.py | 163 +++++++++++++++ src/domains/agents/router.py | 84 ++++++++ src/domains/agents/schemas.py | 30 +++ src/domains/router.py | 8 +- src/domains/tools/__init__.py | 18 ++ src/domains/tools/base.py | 134 +++++++++++++ src/domains/tools/file/__init__.py | 7 + src/domains/tools/file/glob.py | 135 +++++++++++++ src/domains/tools/file/read.py | 127 ++++++++++++ src/domains/tools/search/__init__.py | 6 + src/domains/tools/search/grep.py | 236 ++++++++++++++++++++++ src/domains/tools/shell/__init__.py | 6 + src/domains/tools/shell/bash.py | 236 ++++++++++++++++++++++ src/ollama/__init__.py | 4 + src/ollama/provider.py | 132 +++++++++++++ tests/test_agents_api.py | 75 +++++++ tests/test_tools.py | 234 ++++++++++++++++++++++ wakeup.sh | 18 +- 45 files changed, 3299 insertions(+), 12 deletions(-) create mode 100755 chat.sh create mode 100644 cli/__init__.py create mode 100644 cli/client.py create mode 100644 cli/main.py create mode 100644 cli/theme.py create mode 100644 docs/mistral-instructions.md create mode 100644 src/cli/__init__.py create mode 100644 src/cli/commands/__init__.py create mode 100644 src/cli/commands/chat.py create mode 100644 src/cli/commands/explore.py create mode 100644 src/cli/commands/version.py create mode 100644 src/cli/main.py create mode 100644 src/cli/session/__init__.py create mode 100644 src/cli/session/context.py create mode 100644 src/cli/session/loop.py create mode 100644 src/cli/theme.py create mode 100644 src/cli/ui/__init__.py create mode 100644 src/cli/ui/console.py create mode 100644 src/cli/ui/display.py create mode 100644 src/domains/agents/base.py create mode 100644 src/domains/agents/explore/agent.py create mode 100644 src/domains/agents/explore/prompts.py create mode 100644 src/domains/agents/explore/tools.py create mode 100644 src/domains/agents/router.py create mode 100644 src/domains/agents/schemas.py create mode 100644 src/domains/tools/base.py create mode 100644 src/domains/tools/file/glob.py create mode 100644 src/domains/tools/file/read.py create mode 100644 src/domains/tools/search/grep.py create mode 100644 src/domains/tools/shell/bash.py create mode 100644 src/ollama/__init__.py create mode 100644 src/ollama/provider.py create mode 100644 tests/test_agents_api.py create mode 100644 tests/test_tools.py diff --git a/AGENTS.md b/AGENTS.md index 7ed0e7a..aa3fa85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,18 @@ curl http://localhost:8086/docs # Swagger UI --- +## 1.5 Known Issues & Future Improvements + +### Explore Agent + +- **Gitignore Support**: The filesystem tools (`glob_files`, `grep_content`) currently do NOT honor `.gitignore`. They return results from ignored directories like `.venv/`, `node_modules/`, etc. This should be fixed to filter out gitignored files by default. + +- **Model Hallucination**: Mistral Nemo sometimes hallucinates file contents instead of using actual tool results. Consider using a more capable model (codestral, qwen2.5-coder) or adding response validation. + +- **Ollama Provider**: We use a custom `WebberOllamaProvider` (ported from tatlock) that sanitizes `content: null` to `content: ""` for assistant messages with tool calls. This works around an Ollama API limitation. + +--- + ## 2. FastAPI Architecture & Best Practices *Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)* diff --git a/chat.sh b/chat.sh new file mode 100755 index 0000000..32e2793 --- /dev/null +++ b/chat.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Start webber chat session +# Usage: ./chat.sh [directory] + +DIR="${1:-.}" + +cd /mnt/media/Projects/webber +source .venv/bin/activate + +echo "Starting Webber chat..." +echo "Working directory: $(realpath "$DIR")" +echo "" + +webber chat -d "$DIR" diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..010e50c --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,7 @@ +""" +Webber CLI Client. + +A standalone CLI that communicates with the Webber API backend. +Can be extracted as a separate package. +""" +__version__ = "0.1.0" diff --git a/cli/client.py b/cli/client.py new file mode 100644 index 0000000..6aac015 --- /dev/null +++ b/cli/client.py @@ -0,0 +1,141 @@ +""" +Webber API client. + +Communicates with the Webber API backend for agent execution. +""" +import httpx +from dataclasses import dataclass +from typing import Any + + +@dataclass +class AgentResponse: + """Response from agent execution.""" + response: str + agent_type: str + success: bool + error: str | None = None + + +@dataclass +class AgentInfo: + """Information about an available agent.""" + name: str + description: str + + +class WebberClient: + """ + Client for the Webber API. + + Usage: + client = WebberClient("http://localhost:8086") + response = await client.run_agent("explore", "find python files", "/path/to/project") + """ + + def __init__( + self, + base_url: str = "http://localhost:8086", + api_key: str | None = None, + timeout: float = 120.0, + ): + """ + Initialize the Webber client. + + Args: + base_url: Webber API URL + api_key: Optional API key for authentication + timeout: Request timeout in seconds + """ + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create the HTTP client.""" + if self._client is None or self._client.is_closed: + headers = {} + if self.api_key: + headers["X-API-Key"] = self.api_key + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers=headers, + timeout=self.timeout, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def health_check(self) -> bool: + """Check if the API is healthy.""" + try: + client = await self._get_client() + response = await client.get("/health") + return response.status_code == 200 + except httpx.RequestError: + return False + + async def list_agents(self) -> list[AgentInfo]: + """List available agents.""" + client = await self._get_client() + response = await client.get("/agents/") + response.raise_for_status() + data = response.json() + return [AgentInfo(**a) for a in data.get("agents", [])] + + async def get_agent(self, agent_type: str) -> AgentInfo | None: + """Get information about a specific agent.""" + client = await self._get_client() + response = await client.get(f"/agents/{agent_type}") + if response.status_code == 404: + return None + response.raise_for_status() + return AgentInfo(**response.json()) + + async def run_agent( + self, + agent_type: str, + prompt: str, + working_dir: str = ".", + ) -> AgentResponse: + """ + Run an agent with the given prompt. + + Args: + agent_type: Type of agent (e.g., "explore") + prompt: User prompt/query + working_dir: Working directory for the agent + + Returns: + AgentResponse with the result + """ + client = await self._get_client() + response = await client.post( + "/agents/run", + json={ + "agent_type": agent_type, + "prompt": prompt, + "working_dir": working_dir, + }, + ) + response.raise_for_status() + data = response.json() + return AgentResponse( + response=data.get("response", ""), + agent_type=data.get("agent_type", agent_type), + success=data.get("success", True), + error=data.get("error"), + ) + + async def __aenter__(self) -> "WebberClient": + """Async context manager entry.""" + return self + + async def __aexit__(self, *args: Any) -> None: + """Async context manager exit.""" + await self.close() diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..133575d --- /dev/null +++ b/cli/main.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +Webber CLI - Client for the Webber API. + +Usage: + webber-cli --help + webber-cli chat [OPTIONS] + webber-cli explore QUERY [OPTIONS] +""" +import asyncio +import os +from pathlib import Path + +import typer +from rich.markdown import Markdown +from rich.panel import Panel + +from cli.client import WebberClient +from cli.theme import get_console, get_theme + +app = typer.Typer( + name="webber-cli", + help="CLI client for the Webber API", + no_args_is_help=True, + add_completion=False, +) + +console = get_console() + +# Default API URL (can be overridden via env or option) +# Development port is 8095, production is 8086 +DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095") + + +def version_callback(value: bool) -> None: + """Display version and exit.""" + if value: + from cli import __version__ + console.print(f"[title]webber-cli[/] version [success]{__version__}[/]") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + False, + "--version", + "-v", + callback=version_callback, + is_eager=True, + help="Show version and exit", + ), +) -> None: + """Webber CLI - Talk to the Webber API.""" + pass + + +@app.command() +def chat( + directory: str = typer.Option( + ".", + "--directory", + "-d", + help="Working directory for exploration", + ), + api_url: str = typer.Option( + DEFAULT_API_URL, + "--api", + "-a", + help="Webber API URL", + ), + agent: str = typer.Option( + "explore", + "--agent", + help="Agent to use", + ), +) -> None: + """ + Start interactive chat session. + + Connects to the Webber API backend for agent execution. + """ + working_dir = str(Path(directory).resolve()) + + if not Path(working_dir).exists(): + console.print(f"[error]Error:[/] Directory not found: {working_dir}") + raise typer.Exit(1) + + try: + asyncio.run(_chat_loop(api_url, agent, working_dir)) + except KeyboardInterrupt: + console.print("\n[dim]Goodbye![/]") + + +async def _chat_loop(api_url: str, agent_type: str, working_dir: str) -> None: + """Interactive chat loop.""" + theme = get_theme() + + async with WebberClient(api_url) as client: + # Check API health + if not await client.health_check(): + console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") + console.print("[dim]Make sure the server is running: ./wakeup.sh[/]") + return + + # Get agent info + agent_info = await client.get_agent(agent_type) + if not agent_info: + console.print(f"[error]Error:[/] Unknown agent: {agent_type}") + agents = await client.list_agents() + console.print("[dim]Available agents:[/]") + for a in agents: + console.print(f" - {a.name}: {a.description}") + return + + # Welcome message + console.print() + console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]") + console.print(f"[dim]Working in:[/] [path]{working_dir}[/]") + console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}") + console.print() + console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]") + console.print() + + # Chat loop + while True: + try: + user_input = console.input("[prompt]>[/] ").strip() + + if not user_input: + continue + + if user_input.lower() in ("exit", "quit", "/exit", "/quit"): + console.print("[dim]Goodbye![/]") + break + + if user_input.lower() in ("clear", "/clear"): + console.clear() + continue + + if user_input.lower().startswith("cd "): + new_dir = user_input[3:].strip() + new_path = Path(new_dir).resolve() + if new_path.exists() and new_path.is_dir(): + working_dir = str(new_path) + console.print(f"[info]Changed to:[/] [path]{working_dir}[/]") + else: + console.print(f"[error]Directory not found:[/] {new_dir}") + continue + + # Call the API + with console.status("[info]Thinking...[/]", spinner=theme.spinner): + result = await client.run_agent(agent_type, user_input, working_dir) + + console.print() + if result.success: + console.print(Markdown(result.response)) + else: + console.print(f"[error]Error:[/] {result.error}") + console.print() + + except KeyboardInterrupt: + console.print("\n[dim]Use 'exit' to quit.[/]") + + except Exception as e: + console.print(f"[error]Error:[/] {e}") + + +@app.command() +def explore( + query: str = typer.Argument(..., help="What to search for"), + directory: str = typer.Option( + ".", + "--directory", + "-d", + help="Working directory", + ), + api_url: str = typer.Option( + DEFAULT_API_URL, + "--api", + "-a", + help="Webber API URL", + ), +) -> None: + """ + One-shot codebase exploration. + + Sends a query to the Webber API and displays the result. + """ + working_dir = str(Path(directory).resolve()) + + if not Path(working_dir).exists(): + console.print(f"[error]Error:[/] Directory not found: {working_dir}") + raise typer.Exit(1) + + asyncio.run(_explore(api_url, query, working_dir)) + + +async def _explore(api_url: str, query: str, working_dir: str) -> None: + """Execute exploration query.""" + theme = get_theme() + + async with WebberClient(api_url) as client: + # Check API health + if not await client.health_check(): + console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}") + console.print("[dim]Make sure the server is running: ./wakeup.sh[/]") + return + + console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]") + console.print(f"[dim]Query:[/] {query}") + console.print() + + with console.status("[info]Searching...[/]", spinner=theme.spinner): + result = await client.run_agent("explore", query, working_dir) + + if result.success: + console.print(Panel( + Markdown(result.response), + title="[success]Findings[/]", + border_style=theme.colors.border_success, + )) + else: + console.print(Panel( + f"[error]{result.error}[/]", + title="[error]Error[/]", + border_style=theme.colors.border_error, + )) + + +@app.command() +def status( + api_url: str = typer.Option( + DEFAULT_API_URL, + "--api", + "-a", + help="Webber API URL", + ), +) -> None: + """Check API status and list available agents.""" + asyncio.run(_status(api_url)) + + +async def _status(api_url: str) -> None: + """Check API status.""" + async with WebberClient(api_url) as client: + console.print(f"[dim]API URL:[/] {api_url}") + + if await client.health_check(): + console.print("[success]Status:[/] Connected") + + agents = await client.list_agents() + console.print(f"\n[dim]Available agents ({len(agents)}):[/]") + for agent in agents: + console.print(f" [info]{agent.name}[/]: {agent.description}") + else: + console.print("[error]Status:[/] Cannot connect") + + +if __name__ == "__main__": + app() diff --git a/cli/theme.py b/cli/theme.py new file mode 100644 index 0000000..e647bb3 --- /dev/null +++ b/cli/theme.py @@ -0,0 +1,75 @@ +""" +CLI theme configuration. + +Centralized color and style definitions. +""" +from dataclasses import dataclass +from functools import lru_cache + +from rich.console import Console +from rich.theme import Theme + + +@dataclass(frozen=True) +class ThemeColors: + """Color palette for the CLI.""" + + # Semantic colors + info: str = "steel_blue" + warning: str = "dark_orange" + error: str = "red3" + success: str = "sea_green3" + + # UI elements + prompt: str = "steel_blue bold" + title: str = "steel_blue bold" + path: str = "steel_blue underline" + code: str = "sea_green3" + highlight: str = "medium_purple1" + dim: str = "dim white" + + # Panel borders + border_default: str = "steel_blue" + border_success: str = "sea_green3" + border_error: str = "red3" + border_warning: str = "dark_orange" + + +@dataclass(frozen=True) +class ThemeConfig: + """Complete theme configuration.""" + + colors: ThemeColors = ThemeColors() + spinner: str = "dots" + syntax_theme: str = "monokai" + + def to_rich_theme_dict(self) -> dict[str, str]: + """Convert to Rich theme dictionary.""" + return { + "info": self.colors.info, + "warning": self.colors.warning, + "error": self.colors.error, + "success": self.colors.success, + "prompt": self.colors.prompt, + "title": self.colors.title, + "path": self.colors.path, + "code": self.colors.code, + "highlight": self.colors.highlight, + "dim": self.colors.dim, + } + + +DEFAULT_THEME = ThemeConfig() + + +def get_theme() -> ThemeConfig: + """Get the current theme configuration.""" + return DEFAULT_THEME + + +@lru_cache +def get_console() -> Console: + """Get the shared console instance with theme applied.""" + theme = get_theme() + rich_theme = Theme(theme.to_rich_theme_dict()) + return Console(theme=rich_theme) diff --git a/docs/mistral-instructions.md b/docs/mistral-instructions.md new file mode 100644 index 0000000..8bab70d --- /dev/null +++ b/docs/mistral-instructions.md @@ -0,0 +1,14 @@ +## Background + +Research with Gemini identified key issues with mistral-nemo and tool calling: +- "Pre-computation Hallucination" - model answers before using tools +- High default temperature (0.7-0.8) causes wandering +- Model is "chatty and confident" - needs explicit constraints + +## Key Recommendations from Gemini Research + +1. **Temperature 0.0** for tool-calling agents (deterministic, follows schema) +2. **Chain of Thought (CoT)** - force step-by-step reasoning +3. **Negative constraints** - tell model what NOT to do (Nemo responds better) +4. **Explicit tool descriptions** - verbose docstrings with "never estimate yourself" +5. **"Strictly tool-based assistant"** pattern - NO internal knowledge claim diff --git a/pyproject.toml b/pyproject.toml index 2d23b23..628831b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,13 +15,17 @@ classifiers = [ "Topic :: Software Development :: Code Generators", ] +[project.scripts] +webber = "src.cli.main:app" +webber-cli = "cli.main:app" + [build-system] requires = ["setuptools>=75.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["."] -include = ["src*"] +include = ["src*", "cli*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index 8874d11..22bd357 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,10 @@ pydantic-ai~=1.40.0 httpx~=0.28.1 aiofiles~=25.1.0 +# CLI +typer~=0.15.0 +rich~=13.9.0 + # Utilities python-multipart~=0.0.21 python-dotenv~=1.2.1 diff --git a/src/cli/__init__.py b/src/cli/__init__.py new file mode 100644 index 0000000..0306ca9 --- /dev/null +++ b/src/cli/__init__.py @@ -0,0 +1,3 @@ +""" +Webber CLI - Command-line interface for the multi-agent system. +""" diff --git a/src/cli/commands/__init__.py b/src/cli/commands/__init__.py new file mode 100644 index 0000000..ddf00c3 --- /dev/null +++ b/src/cli/commands/__init__.py @@ -0,0 +1,6 @@ +""" +CLI commands. +""" +from src.cli.commands import chat, explore, version + +__all__ = ["chat", "explore", "version"] diff --git a/src/cli/commands/chat.py b/src/cli/commands/chat.py new file mode 100644 index 0000000..bb06bad --- /dev/null +++ b/src/cli/commands/chat.py @@ -0,0 +1,128 @@ +""" +Chat command - interactive conversation mode. +""" +import asyncio +from pathlib import Path + +import typer + +from src.cli.theme import get_theme +from src.cli.ui.console import get_console +from src.cli.session.loop import AgenticLoop +from src.shared.logging import setup_logging + +console = get_console() + + +def chat_command( + directory: str = typer.Option( + ".", + "--directory", + "-d", + help="Working directory to explore", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-V", + help="Show detailed output and debug logging", + ), +) -> None: + """ + Start interactive chat session. + + Enters a conversation loop where you can ask questions about the codebase. + The explore agent will search files, read code, and answer questions. + + Examples: + webber chat + webber chat -d ./src + webber chat --verbose + """ + # Set up logging + log_level = "DEBUG" if verbose else "WARNING" + setup_logging(log_level) + + # Resolve directory + working_dir = str(Path(directory).resolve()) + + if not Path(working_dir).exists(): + console.print(f"[error]Error:[/] Directory not found: {working_dir}") + raise typer.Exit(1) + + # Run the async chat loop + try: + asyncio.run(_chat_loop(working_dir, verbose)) + except KeyboardInterrupt: + console.print("\n[dim]Goodbye![/]") + + +async def _chat_loop(working_dir: str, verbose: bool) -> None: + """Async chat loop implementation.""" + from src.domains.agents.explore import explore_agent + + # Create the agentic loop + loop = AgenticLoop( + agent=explore_agent, + console=console, + working_dir=working_dir, + ) + + # Display welcome + loop.display_welcome() + + # Main conversation loop + while True: + try: + # Get user input + user_input = console.input("[prompt]>[/] ").strip() + + # Handle special commands + if not user_input: + continue + + if user_input.lower() in ("exit", "quit", "/exit", "/quit"): + console.print("[dim]Goodbye![/]") + break + + if user_input.lower() in ("clear", "/clear"): + loop.state.clear_history() + console.print("[info]History cleared.[/]") + continue + + if user_input.lower() in ("status", "/status"): + loop.display_status() + continue + + if user_input.lower().startswith("cd "): + new_dir = user_input[3:].strip() + new_path = Path(new_dir).resolve() + if new_path.exists() and new_path.is_dir(): + loop.set_working_dir(str(new_path)) + else: + console.print(f"[error]Directory not found:[/] {new_dir}") + continue + + # Process with agent + theme = get_theme() + with console.status("[info]Thinking...[/]", spinner=theme.spinner): + response = await loop.run_turn(user_input) + + # Display response + console.print() + loop.display_response(response) + console.print() + + except KeyboardInterrupt: + console.print("\n[dim]Use 'exit' to quit or press Ctrl+C again.[/]") + try: + # Wait briefly for second Ctrl+C + await asyncio.sleep(0.5) + except KeyboardInterrupt: + console.print("\n[dim]Goodbye![/]") + break + + except Exception as e: + console.print(f"[error]Error:[/] {e}") + if verbose: + console.print_exception() diff --git a/src/cli/commands/explore.py b/src/cli/commands/explore.py new file mode 100644 index 0000000..4d285c1 --- /dev/null +++ b/src/cli/commands/explore.py @@ -0,0 +1,83 @@ +""" +Explore command - one-shot codebase exploration. +""" +import asyncio +from pathlib import Path + +import typer +from rich.panel import Panel + +from src.cli.theme import get_theme +from src.cli.ui.console import get_console +from src.cli.ui.display import format_response +from src.shared.logging import setup_logging + +console = get_console() + + +def explore_command( + query: str = typer.Argument(..., help="What to search for in the codebase"), + directory: str = typer.Option( + ".", + "--directory", + "-d", + help="Working directory to explore", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-V", + help="Show detailed output", + ), +) -> None: + """ + One-shot codebase exploration. + + Searches the codebase for the given query and returns findings. + + Examples: + webber explore "where is config loaded" + webber explore "find all API endpoints" -d ./src + webber explore "how does authentication work" + """ + # Set up logging based on verbosity + log_level = "DEBUG" if verbose else "WARNING" + setup_logging(log_level) + + # Resolve directory + working_dir = str(Path(directory).resolve()) + + if not Path(working_dir).exists(): + console.print(f"[error]Error:[/] Directory not found: {working_dir}") + raise typer.Exit(1) + + console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]") + console.print(f"[dim]Query:[/] {query}\n") + + # Run the exploration + asyncio.run(_explore_async(query, working_dir, verbose)) + + +async def _explore_async(query: str, working_dir: str, verbose: bool) -> None: + """Async exploration implementation.""" + from src.domains.agents.explore import explore + + theme = get_theme() + + try: + with console.status("[info]Searching codebase...[/]", spinner=theme.spinner): + result = await explore(query, working_dir=working_dir) + + # Display result + formatted = format_response(result) + console.print(Panel( + formatted, + title="[success]Findings[/]", + border_style=theme.colors.border_success, + )) + + except Exception as e: + console.print(f"[error]Error:[/] {e}") + if verbose: + console.print_exception() + raise typer.Exit(1) diff --git a/src/cli/commands/version.py b/src/cli/commands/version.py new file mode 100644 index 0000000..22dd67b --- /dev/null +++ b/src/cli/commands/version.py @@ -0,0 +1,26 @@ +""" +Version command. +""" +from rich.console import Console +from rich.panel import Panel + +from src.shared.config import get_settings + +console = Console() + + +def show_version() -> None: + """Display version information.""" + settings = get_settings() + + version_info = f"""[bold blue]{settings.app_name}[/] [green]v{settings.app_version}[/] + +{settings.app_description} + +[dim]Configuration:[/] + Ollama URL: {settings.ollama_url} + Model: {settings.ollama_agent_model} + Debug: {settings.debug} +""" + + console.print(Panel(version_info, title="Version Info", border_style="blue")) diff --git a/src/cli/main.py b/src/cli/main.py new file mode 100644 index 0000000..b910935 --- /dev/null +++ b/src/cli/main.py @@ -0,0 +1,64 @@ +""" +Webber CLI main entry point. + +Usage: + webber --help + webber --version + webber chat [OPTIONS] + webber explore QUERY [OPTIONS] +""" +import typer +from rich.console import Console + +from src.shared.config import get_settings + +# Create Typer app +app = typer.Typer( + name="webber", + help="Multi-Agent AI Development System", + no_args_is_help=True, + add_completion=False, +) + +console = Console() + + +def version_callback(value: bool) -> None: + """Display version and exit.""" + if value: + settings = get_settings() + console.print(f"[bold blue]{settings.app_name}[/] version [green]{settings.app_version}[/]") + console.print(f"[dim]{settings.app_description}[/]") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + False, + "--version", + "-v", + callback=version_callback, + is_eager=True, + help="Show version and exit", + ), +) -> None: + """ + Webber - Multi-Agent AI Development System. + + A CLI tool for codebase exploration and development assistance + powered by local LLMs via Ollama. + """ + pass + + +# Import and register commands +from src.cli.commands import chat, explore, version # noqa: E402, F401 + +# Register subcommands +app.command(name="chat")(chat.chat_command) +app.command(name="explore")(explore.explore_command) + + +if __name__ == "__main__": + app() diff --git a/src/cli/session/__init__.py b/src/cli/session/__init__.py new file mode 100644 index 0000000..14fb096 --- /dev/null +++ b/src/cli/session/__init__.py @@ -0,0 +1,7 @@ +""" +Session management for CLI. +""" +from src.cli.session.context import SessionState +from src.cli.session.loop import AgenticLoop + +__all__ = ["SessionState", "AgenticLoop"] diff --git a/src/cli/session/context.py b/src/cli/session/context.py new file mode 100644 index 0000000..030389c --- /dev/null +++ b/src/cli/session/context.py @@ -0,0 +1,60 @@ +""" +Session state management. +""" +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal + + +@dataclass +class Message: + """Single message in conversation history.""" + role: Literal["user", "assistant", "system"] + content: str + timestamp: datetime = field(default_factory=datetime.now) + + def __str__(self) -> str: + return f"[{self.role}] {self.content[:50]}..." + + +@dataclass +class SessionState: + """ + Persistent state for a CLI session. + + Tracks conversation history and context. + """ + working_dir: str + messages: list[Message] = field(default_factory=list) + started_at: datetime = field(default_factory=datetime.now) + + # Token tracking (for future context management) + estimated_tokens: int = 0 + max_tokens: int = 128000 + + def add_message(self, role: Literal["user", "assistant", "system"], content: str) -> None: + """Add a message to history.""" + self.messages.append(Message(role=role, content=content)) + # Rough token estimate (4 chars per token) + self.estimated_tokens += len(content) // 4 + + def get_history(self, limit: int | None = None) -> list[Message]: + """Get recent message history.""" + if limit: + return self.messages[-limit:] + return self.messages + + def clear_history(self) -> None: + """Clear message history.""" + self.messages.clear() + self.estimated_tokens = 0 + + @property + def message_count(self) -> int: + """Number of messages in history.""" + return len(self.messages) + + @property + def is_near_limit(self) -> bool: + """Check if approaching token limit.""" + return self.estimated_tokens > (self.max_tokens * 0.8) diff --git a/src/cli/session/loop.py b/src/cli/session/loop.py new file mode 100644 index 0000000..e2d745c --- /dev/null +++ b/src/cli/session/loop.py @@ -0,0 +1,104 @@ +""" +Agentic conversation loop for interactive CLI. +""" +from typing import Any + +from rich.console import Console + +from src.cli.session.context import SessionState +from src.cli.ui.display import format_response +from src.domains.agents.base import BaseAgent +from src.shared.logging import logged, trace_span, get_logger + +logger = get_logger(__name__) + + +class AgenticLoop: + """ + Main conversation loop for interactive CLI sessions. + + Manages state, executes agent turns, and handles display. + """ + + def __init__( + self, + agent: BaseAgent, + console: Console, + working_dir: str, + ): + """ + Initialize the agentic loop. + + Args: + agent: The agent to use for responses + console: Rich console for output + working_dir: Working directory for exploration + """ + self.agent = agent + self.console = console + self.state = SessionState(working_dir=working_dir) + + @logged() + async def run_turn(self, user_input: str) -> str: + """ + Execute a single conversation turn. + + Args: + user_input: User's prompt/question + + Returns: + Agent's response + """ + # Record user message + self.state.add_message("user", user_input) + + async with trace_span("agentic_turn"): + try: + # Run the agent + response = await self.agent.run( + user_input, + working_dir=self.state.working_dir, + ) + + # Record assistant response + self.state.add_message("assistant", response) + + return response + + except Exception as e: + logger.exception(f"Agent error: {e}") + error_msg = f"Error: {e}" + self.state.add_message("assistant", error_msg) + raise + + def display_response(self, response: str) -> None: + """Display agent response with formatting.""" + formatted = format_response(response) + self.console.print(formatted) + + def display_welcome(self) -> None: + """Display welcome message.""" + from src.shared.config import get_settings + settings = get_settings() + + self.console.print() + self.console.print(f"[title]{settings.app_name}[/] [dim]v{settings.app_version}[/]") + self.console.print(f"[dim]Working in:[/] [path]{self.state.working_dir}[/]") + self.console.print(f"[dim]Agent:[/] {self.agent.name} - {self.agent.description}") + self.console.print() + self.console.print("[dim]Type 'exit' or Ctrl+C to quit. Type 'clear' to reset history.[/]") + self.console.print() + + def display_status(self) -> None: + """Display session status.""" + self.console.print(f"[dim]Messages: {self.state.message_count} | Tokens: ~{self.state.estimated_tokens}[/]") + + @property + def working_dir(self) -> str: + """Get current working directory.""" + return self.state.working_dir + + def set_working_dir(self, path: str) -> None: + """Change working directory.""" + self.state.working_dir = path + self.console.print(f"[info]Changed directory to:[/] [path]{path}[/]") diff --git a/src/cli/theme.py b/src/cli/theme.py new file mode 100644 index 0000000..470be46 --- /dev/null +++ b/src/cli/theme.py @@ -0,0 +1,70 @@ +""" +CLI theme configuration. + +Centralized color and style definitions for the Webber CLI. +All color choices should be defined here for easy customization. +""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ThemeColors: + """Color palette for the CLI.""" + + # Semantic colors + info: str = "steel_blue" + warning: str = "dark_orange" + error: str = "red3" + success: str = "sea_green3" + + # UI elements + prompt: str = "steel_blue bold" + title: str = "steel_blue bold" + path: str = "steel_blue underline" + code: str = "sea_green3" + highlight: str = "medium_purple1" + dim: str = "dim white" + + # Panel borders + border_default: str = "steel_blue" + border_success: str = "sea_green3" + border_error: str = "red3" + border_warning: str = "dark_orange" + + +@dataclass(frozen=True) +class ThemeConfig: + """Complete theme configuration.""" + + colors: ThemeColors = ThemeColors() + + # Spinner style for loading indicators + spinner: str = "dots" + + # Code syntax highlighting theme + syntax_theme: str = "monokai" + + def to_rich_theme_dict(self) -> dict[str, str]: + """Convert to Rich theme dictionary.""" + return { + "info": self.colors.info, + "warning": self.colors.warning, + "error": self.colors.error, + "success": self.colors.success, + "prompt": self.colors.prompt, + "title": self.colors.title, + "path": self.colors.path, + "code": self.colors.code, + "highlight": self.colors.highlight, + "dim": self.colors.dim, + } + + +# Default theme instance +DEFAULT_THEME = ThemeConfig() + + +def get_theme() -> ThemeConfig: + """Get the current theme configuration.""" + # Future: could load from config file or env vars + return DEFAULT_THEME diff --git a/src/cli/ui/__init__.py b/src/cli/ui/__init__.py new file mode 100644 index 0000000..7eeee51 --- /dev/null +++ b/src/cli/ui/__init__.py @@ -0,0 +1,7 @@ +""" +CLI UI components. +""" +from src.cli.ui.console import get_console +from src.cli.ui.display import format_response, format_code + +__all__ = ["get_console", "format_response", "format_code"] diff --git a/src/cli/ui/console.py b/src/cli/ui/console.py new file mode 100644 index 0000000..be32d0b --- /dev/null +++ b/src/cli/ui/console.py @@ -0,0 +1,37 @@ +""" +Rich console helpers. +""" +from functools import lru_cache + +from rich.console import Console +from rich.theme import Theme + +from src.cli.theme import get_theme + + +@lru_cache +def get_console() -> Console: + """Get the shared console instance with theme applied.""" + theme = get_theme() + rich_theme = Theme(theme.to_rich_theme_dict()) + return Console(theme=rich_theme) + + +def print_info(message: str) -> None: + """Print an info message.""" + get_console().print(f"[info]{message}[/]") + + +def print_warning(message: str) -> None: + """Print a warning message.""" + get_console().print(f"[warning]Warning:[/] {message}") + + +def print_error(message: str) -> None: + """Print an error message.""" + get_console().print(f"[error]Error:[/] {message}") + + +def print_success(message: str) -> None: + """Print a success message.""" + get_console().print(f"[success]{message}[/]") diff --git a/src/cli/ui/display.py b/src/cli/ui/display.py new file mode 100644 index 0000000..3a7e07b --- /dev/null +++ b/src/cli/ui/display.py @@ -0,0 +1,84 @@ +""" +Output formatting and display helpers. +""" +import re + +from rich.markdown import Markdown +from rich.syntax import Syntax +from rich.text import Text + +from src.cli.theme import get_theme +from src.cli.ui.console import get_console + + +def format_response(text: str) -> Markdown | Text: + """ + Format agent response for display. + + Detects markdown and formats appropriately. + """ + # Check if response contains markdown patterns + has_markdown = any([ + "```" in text, # Code blocks + text.startswith("#"), # Headers + "**" in text or "__" in text, # Bold + "- " in text or "* " in text, # Lists + ]) + + if has_markdown: + return Markdown(text) + else: + return Text(text) + + +def format_code(code: str, language: str = "python") -> Syntax: + """ + Format code with syntax highlighting. + + Args: + code: Source code to format + language: Programming language for highlighting + """ + theme = get_theme() + return Syntax( + code, + language, + theme=theme.syntax_theme, + line_numbers=True, + word_wrap=True, + ) + + +def format_file_path(path: str, line: int | None = None) -> Text: + """ + Format a file path for display. + + Args: + path: File path + line: Optional line number + """ + text = Text() + text.append(path, style="path") + if line: + text.append(f":{line}", style="dim") + return text + + +def truncate_text(text: str, max_length: int = 500, suffix: str = "...") -> str: + """ + Truncate text to maximum length. + + Args: + text: Text to truncate + max_length: Maximum character length + suffix: Suffix to add if truncated + """ + if len(text) <= max_length: + return text + return text[:max_length - len(suffix)] + suffix + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from text.""" + ansi_pattern = re.compile(r'\x1b\[[0-9;]*m') + return ansi_pattern.sub('', text) diff --git a/src/domains/agents/__init__.py b/src/domains/agents/__init__.py index e69de29..9aa954d 100644 --- a/src/domains/agents/__init__.py +++ b/src/domains/agents/__init__.py @@ -0,0 +1,37 @@ +""" +Agent implementations. + +All agents inherit from BaseAgent and are registered in the global registry. +""" +from src.domains.agents.base import ( + BaseAgent, + AgentContext, + AgentProtocol, + register_agent, + get_agent, + list_agents, + get_registry, +) +from src.domains.agents.explore import ( + ExploreAgentImpl, + ExploreContext, + explore_agent, + explore, +) + +__all__ = [ + # Base classes + "BaseAgent", + "AgentContext", + "AgentProtocol", + # Registry functions + "register_agent", + "get_agent", + "list_agents", + "get_registry", + # Explore agent + "ExploreAgentImpl", + "ExploreContext", + "explore_agent", + "explore", +] diff --git a/src/domains/agents/base.py b/src/domains/agents/base.py new file mode 100644 index 0000000..d834928 --- /dev/null +++ b/src/domains/agents/base.py @@ -0,0 +1,167 @@ +""" +Base classes and registry for agent implementations. + +All agents are built on PydanticAI and registered in a central registry. +""" +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Protocol, runtime_checkable + +from pydantic_ai import Agent + +from src.shared.logging import get_logger + +logger = get_logger(__name__) + + +@dataclass +class AgentContext: + """ + Base context passed to all agent tools. + + Subclass this for agent-specific context (e.g., ExploreContext). + """ + working_dir: str + allowed_paths: list[str] = field(default_factory=list) + timeout_seconds: int = 120 + + +@runtime_checkable +class AgentProtocol(Protocol): + """Protocol that all agents must implement.""" + + @property + def name(self) -> str: + """Unique identifier for the agent.""" + ... + + @property + def description(self) -> str: + """Human-readable description of what the agent does.""" + ... + + @property + def agent(self) -> Agent: + """The underlying PydanticAI agent.""" + ... + + async def run(self, prompt: str, **kwargs: Any) -> str: + """ + Execute the agent with a prompt. + + Args: + prompt: User prompt/query + **kwargs: Additional arguments (working_dir, etc.) + + Returns: + Agent response as string + """ + ... + + +class BaseAgent(ABC): + """ + Abstract base class for agent implementations. + + Provides common functionality and enforces interface. + + Usage: + class ExploreAgent(BaseAgent): + name = "explore" + description = "Fast codebase exploration" + + def _create_agent(self) -> Agent: + # Create and configure PydanticAI agent + ... + + async def run(self, prompt: str, **kwargs) -> str: + # Execute agent + ... + """ + + @property + @abstractmethod + def name(self) -> str: + """Unique identifier for the agent.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Human-readable description.""" + pass + + @property + def agent(self) -> Agent: + """Lazy-loaded PydanticAI agent.""" + if not hasattr(self, '_agent') or self._agent is None: + self._agent = self._create_agent() + return self._agent + + @abstractmethod + def _create_agent(self) -> Agent: + """ + Create and configure the PydanticAI agent. + + Override this to set up model, system prompt, and tools. + """ + pass + + @abstractmethod + async def run(self, prompt: str, **kwargs: Any) -> str: + """Execute the agent.""" + pass + + +# === Agent Registry === + +_AGENT_REGISTRY: dict[str, BaseAgent] = {} + + +def register_agent(agent: BaseAgent) -> BaseAgent: + """ + Register an agent in the global registry. + + Args: + agent: Agent instance to register + + Returns: + The registered agent (for decorator chaining) + """ + if agent.name in _AGENT_REGISTRY: + logger.warning(f"Overwriting existing agent: {agent.name}") + + _AGENT_REGISTRY[agent.name] = agent + logger.info(f"Registered agent: {agent.name}") + return agent + + +def get_agent(name: str) -> BaseAgent | None: + """ + Get an agent by name. + + Args: + name: Agent name + + Returns: + Agent instance or None if not found + """ + return _AGENT_REGISTRY.get(name) + + +def list_agents() -> list[dict[str, str]]: + """ + List all registered agents. + + Returns: + List of agent info dicts with name and description + """ + return [ + {"name": agent.name, "description": agent.description} + for agent in _AGENT_REGISTRY.values() + ] + + +def get_registry() -> dict[str, BaseAgent]: + """Get the full agent registry.""" + return _AGENT_REGISTRY.copy() diff --git a/src/domains/agents/explore/__init__.py b/src/domains/agents/explore/__init__.py index e69de29..c8085c5 100644 --- a/src/domains/agents/explore/__init__.py +++ b/src/domains/agents/explore/__init__.py @@ -0,0 +1,16 @@ +""" +Explore Agent - Fast codebase exploration. +""" +from src.domains.agents.explore.agent import ( + ExploreAgentImpl, + ExploreContext, + explore_agent, + explore, +) + +__all__ = [ + "ExploreAgentImpl", + "ExploreContext", + "explore_agent", + "explore", +] diff --git a/src/domains/agents/explore/agent.py b/src/domains/agents/explore/agent.py new file mode 100644 index 0000000..a183520 --- /dev/null +++ b/src/domains/agents/explore/agent.py @@ -0,0 +1,127 @@ +""" +Explore Agent implementation using PydanticAI. + +Fast codebase exploration with read-only tools. +Uses sanitized Ollama provider for reliable tool calling. +""" +import os +from dataclasses import dataclass +from typing import Any + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIModel + +from src.domains.agents.base import BaseAgent, AgentContext, register_agent +from src.domains.agents.explore.prompts import EXPLORE_SYSTEM_PROMPT +from src.ollama.provider import get_ollama_provider +from src.shared.config import get_settings +from src.shared.logging import logged, get_logger, trace_span + +logger = get_logger(__name__) + + +@dataclass +class ExploreContext(AgentContext): + """ + Context for explore agent tools. + + Passed to all tool functions via RunContext. + """ + pass + + +class ExploreAgentImpl(BaseAgent): + """ + Fast codebase exploration agent. + + Uses glob, grep, read, and bash tools to search and analyze codebases. + Read-only mode - cannot modify files. + """ + + name = "explore" + description = "Fast codebase exploration - find files, search content, read code" + + def __init__(self): + """Initialize the explore agent.""" + self._agent: Agent[ExploreContext, str] | None = None + self._settings = get_settings() + + def _create_agent(self) -> Agent[ExploreContext, str]: + """Create the PydanticAI agent with Ollama backend.""" + # Use sanitized Ollama provider to fix content: null issues + model = OpenAIModel( + model_name=self._settings.ollama_agent_model, + provider=get_ollama_provider(), + ) + + agent: Agent[ExploreContext, str] = Agent( + model=model, + system_prompt=EXPLORE_SYSTEM_PROMPT, + deps_type=ExploreContext, + output_type=str, + # Mistral Nemo settings: + # - temperature 0.3 (Nemo needs slightly higher than 0.0) + # - tool_choice "required" forces tool use + model_settings={ + "temperature": 0.3, + "extra_body": {"tool_choice": "required"}, + }, + ) + + # Register tools + self._register_tools(agent) + + return agent + + def _register_tools(self, agent: Agent[ExploreContext, str]) -> None: + """Register all exploration tools with the agent.""" + from src.domains.agents.explore.tools import register_explore_tools + register_explore_tools(agent) + + @logged() + async def run( + self, + prompt: str, + working_dir: str | None = None, + allowed_paths: list[str] | None = None, + **kwargs: Any + ) -> str: + """ + Run the explore agent with a prompt. + + Args: + prompt: User query about the codebase + working_dir: Working directory for exploration + allowed_paths: Restrict tool access to these paths + + Returns: + Agent response with findings + """ + ctx = ExploreContext( + working_dir=working_dir or os.getcwd(), + allowed_paths=allowed_paths or self._settings.effective_allowed_paths, + timeout_seconds=self._settings.tool_timeout_seconds, + ) + + async with trace_span("explore_agent_run"): + try: + # Use run() not run_stream() - Ollama has bugs with streaming + tools + result = await self.agent.run(prompt, deps=ctx) + return result.output + except Exception as e: + logger.exception(f"Explore agent error: {e}") + raise + + +# Create and register the singleton instance +explore_agent = ExploreAgentImpl() +register_agent(explore_agent) + + +async def explore( + prompt: str, + working_dir: str | None = None, + **kwargs: Any +) -> str: + """Run exploration query.""" + return await explore_agent.run(prompt, working_dir=working_dir, **kwargs) diff --git a/src/domains/agents/explore/prompts.py b/src/domains/agents/explore/prompts.py new file mode 100644 index 0000000..ab82524 --- /dev/null +++ b/src/domains/agents/explore/prompts.py @@ -0,0 +1,98 @@ +""" +System prompts for the Explore agent. + +Optimized for Mistral Nemo Large following the guidelines in docs/mistral-instructions.md: +- Temperature 0.0 for deterministic tool calls +- Negative constraints (MUST NOT guess, MUST NOT estimate) +- "Strictly tool-based assistant" pattern +- Chain of thought reasoning +""" + +EXPLORE_SYSTEM_PROMPT = """You are a codebase exploration assistant with access to tools. + +CRITICAL: You MUST provide ALL required arguments when calling tools. + +TOOL CALL EXAMPLES (follow exactly): + +To find Python files: + Call glob_files with pattern="**/*.py" + +To find a specific file: + Call glob_files with pattern="**/config.py" + +To read a file: + Call read_file with file_path="/absolute/path/to/file.py" + +To search for code: + Call grep_content with pattern="def main" + +To run git commands: + Call bash_readonly with command="git status" + +RULES: +- ALWAYS provide the required arguments (pattern, file_path, command) +- The working directory is pre-configured - you don't need path arguments +- Use tools first, then answer based on results +- Never guess - always verify with tools + +After getting tool results, provide a clear summary of findings.""" + + +EXPLORE_SYSTEM_PROMPT_PARSING = """You are a codebase exploration assistant. Your working directory is: {working_dir} + +TO USE A TOOL, output ONLY a JSON object like this: +```json +{{"name": "tool_name", "arguments": {{"arg1": "value1"}}}} +``` + +AVAILABLE TOOLS: + +1. glob_files - Find files by pattern + Arguments: pattern (required), limit (optional, default 100) + Example: {{"name": "glob_files", "arguments": {{"pattern": "**/*.py"}}}} + +2. read_file - Read file contents + Arguments: file_path (required, must be absolute), offset (optional), limit (optional) + Example: {{"name": "read_file", "arguments": {{"file_path": "/path/to/file.py"}}}} + +3. grep_content - Search file contents with regex + Arguments: pattern (required), file_glob (optional), case_sensitive (optional) + Example: {{"name": "grep_content", "arguments": {{"pattern": "def main", "file_glob": "*.py"}}}} + +4. bash_readonly - Run read-only shell commands (ls, git status, git log, etc.) + Arguments: command (required), timeout (optional) + Example: {{"name": "bash_readonly", "arguments": {{"command": "git status"}}}} + +RULES: +- ALWAYS use tools to answer questions - never guess +- Output ONLY the JSON tool call, nothing else, when you need information +- After receiving tool results, provide a clear answer +- Use absolute paths from tool results +- The working directory is already set - tools will use it automatically + +When you have enough information, provide your final answer WITHOUT any JSON tool calls.""" + + +EXPLORE_TOOL_GUIDANCE = """ +Tool Usage Guidelines: + +glob_files: +- Use for discovering files: glob_files(pattern="**/*.py") +- Filter by directory: glob_files(pattern="*.ts", path="src/") +- Find test files: glob_files(pattern="**/test_*.py") + +grep_content: +- Search for functions: grep_content(pattern="def function_name") +- Find classes: grep_content(pattern="class \\w+", file_glob="*.py") +- Search imports: grep_content(pattern="from.*import", file_glob="*.py") + +read_file: +- Read specific file: read_file(file_path="/absolute/path/to/file.py") +- Read portion: read_file(file_path="/path/file.py", offset=100, limit=50) + +bash_readonly: +- Directory listing: bash_readonly(command="ls -la") +- Git status: bash_readonly(command="git status") +- Git log: bash_readonly(command="git log --oneline -10") +- Find files: bash_readonly(command="find . -name '*.md' -type f") +""" diff --git a/src/domains/agents/explore/tools.py b/src/domains/agents/explore/tools.py new file mode 100644 index 0000000..2e4f6a8 --- /dev/null +++ b/src/domains/agents/explore/tools.py @@ -0,0 +1,163 @@ +""" +Tool registrations for the Explore agent. + +Registers our tool implementations with the PydanticAI agent. +""" +from pydantic_ai import Agent, RunContext + +from src.domains.agents.base import AgentContext +from src.domains.tools.file.read import ReadFileTool +from src.domains.tools.file.glob import GlobFilesTool +from src.domains.tools.search.grep import GrepContentTool +from src.domains.tools.shell.bash import BashReadOnlyTool + + +def register_explore_tools(agent: Agent[AgentContext, str]) -> None: + """ + Register all exploration tools with the agent. + + Each tool is wrapped to use context from RunContext. + """ + + @agent.tool + async def read_file( + ctx: RunContext[AgentContext], + file_path: str, + offset: int = 0, + limit: int = 2000 + ) -> str: + """Read contents of a file with line numbers. + + Args: + file_path: Absolute path to the file to read + offset: Line number to start from (0-based, default: 0) + limit: Maximum number of lines to read (default: 2000) + + Returns: + File contents with line numbers, or error message. + + IMPORTANT: Always use absolute paths. Never guess file contents. + """ + tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths) + result = await tool.execute( + file_path=file_path, + offset=offset, + limit=limit + ) + return result.to_string() + + @agent.tool + async def glob_files( + ctx: RunContext[AgentContext], + pattern: str, + path: str | None = None, + limit: int = 100 + ) -> str: + """Find files matching a glob pattern. + + Args: + pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md") + path: Directory to search in (default: working directory) + limit: Maximum number of files to return (default: 100) + + Returns: + List of absolute file paths, sorted by modification time (newest first). + + Examples: + - "**/*.py" finds all Python files + - "src/**/*.ts" finds TypeScript files in src/ + - "**/test_*.py" finds all test files + + IMPORTANT: Use this to discover files before reading them. + """ + tool = GlobFilesTool(allowed_paths=ctx.deps.allowed_paths) + search_path = path or ctx.deps.working_dir + result = await tool.execute( + pattern=pattern, + path=search_path, + limit=limit + ) + return result.to_string() + + @agent.tool + async def grep_content( + ctx: RunContext[AgentContext], + pattern: str, + path: str | None = None, + file_glob: str | None = None, + context_lines: int = 0, + case_sensitive: bool = True + ) -> str: + """Search file contents using regex pattern. + + Args: + pattern: Regex pattern to search for (Python re syntax) + path: Directory or file to search (default: working directory) + file_glob: Filter files by glob (e.g., "*.py", "*.ts") + context_lines: Lines of context before/after matches (default: 0) + case_sensitive: Case-sensitive search (default: True) + + Returns: + Matching lines with file paths and line numbers. + Format: "filepath:line_num: content" + + Examples: + - pattern="def.*__init__" finds init methods + - pattern="class\\s+\\w+" finds class definitions + - pattern="TODO|FIXME" finds todo comments + + IMPORTANT: Use this to search for code patterns. Escape regex special chars. + """ + tool = GrepContentTool(allowed_paths=ctx.deps.allowed_paths) + search_path = path or ctx.deps.working_dir + result = await tool.execute( + pattern=pattern, + path=search_path, + file_glob=file_glob, + context_lines=context_lines, + case_sensitive=case_sensitive + ) + return result.to_string() + + @agent.tool + async def bash_readonly( + ctx: RunContext[AgentContext], + command: str, + cwd: str | None = None, + timeout: int = 30 + ) -> str: + """Execute a read-only bash command. + + ALLOWED commands: + - File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du + - Git (read-only): git status, git log, git diff, git show, git branch + - Text processing: grep, awk, sed (read-only), sort, uniq + - System info: pwd, whoami, hostname, which + + FORBIDDEN: + - File modification (rm, mv, cp, mkdir, touch) + - Redirects (>, >>) + - Command chaining (&&, ||, ;) + - Network (curl, wget) + + Args: + command: The bash command to execute + cwd: Working directory (default: agent working directory) + timeout: Timeout in seconds (default: 30) + + Returns: + Command output or error message. + + Examples: + - "ls -la" lists files with details + - "git status" shows git status + - "git log --oneline -10" shows recent commits + """ + tool = BashReadOnlyTool(allowed_paths=ctx.deps.allowed_paths) + working_dir = cwd or ctx.deps.working_dir + result = await tool.execute( + command=command, + cwd=working_dir, + timeout=min(timeout, ctx.deps.timeout_seconds) + ) + return result.to_string() diff --git a/src/domains/agents/router.py b/src/domains/agents/router.py new file mode 100644 index 0000000..c7b2ad6 --- /dev/null +++ b/src/domains/agents/router.py @@ -0,0 +1,84 @@ +""" +REST API routes for agents. +""" +from fastapi import APIRouter, HTTPException + +from src.domains.agents.base import get_agent, list_agents + +# Import agents to ensure they're registered +import src.domains.agents.explore # noqa: F401 +from src.domains.agents.schemas import ( + AgentRunRequest, + AgentRunResponse, + AgentInfo, + AgentListResponse, +) +from src.shared.logging import logged, get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/agents", tags=["Agents"]) + + +@router.get("/", response_model=AgentListResponse) +async def list_available_agents() -> AgentListResponse: + """List all available agents.""" + agents = list_agents() + return AgentListResponse( + agents=[AgentInfo(**a) for a in agents] + ) + + +@router.post("/run", response_model=AgentRunResponse) +@logged() +async def run_agent(request: AgentRunRequest) -> AgentRunResponse: + """ + Run an agent with the given prompt. + + The agent will use tools to explore the codebase and answer questions. + """ + # Get the requested agent + agent = get_agent(request.agent_type) + if not agent: + raise HTTPException( + status_code=400, + detail=f"Unknown agent type: {request.agent_type}" + ) + + try: + # Run the agent + response = await agent.run( + request.prompt, + working_dir=request.working_dir, + ) + + return AgentRunResponse( + response=response, + agent_type=request.agent_type, + success=True, + ) + + except Exception as e: + logger.exception(f"Agent execution failed: {e}") + return AgentRunResponse( + response="", + agent_type=request.agent_type, + success=False, + error=str(e), + ) + + +@router.get("/{agent_type}", response_model=AgentInfo) +async def get_agent_info(agent_type: str) -> AgentInfo: + """Get information about a specific agent.""" + agent = get_agent(agent_type) + if not agent: + raise HTTPException( + status_code=404, + detail=f"Agent not found: {agent_type}" + ) + + return AgentInfo( + name=agent.name, + description=agent.description, + ) diff --git a/src/domains/agents/schemas.py b/src/domains/agents/schemas.py new file mode 100644 index 0000000..8eca1bb --- /dev/null +++ b/src/domains/agents/schemas.py @@ -0,0 +1,30 @@ +""" +Request and response schemas for agent API. +""" +from src.shared.base import BaseSchema + + +class AgentRunRequest(BaseSchema): + """Request to run an agent.""" + prompt: str + working_dir: str = "." + agent_type: str = "explore" + + +class AgentRunResponse(BaseSchema): + """Response from agent execution.""" + response: str + agent_type: str + success: bool = True + error: str | None = None + + +class AgentInfo(BaseSchema): + """Information about an agent.""" + name: str + description: str + + +class AgentListResponse(BaseSchema): + """List of available agents.""" + agents: list[AgentInfo] diff --git a/src/domains/router.py b/src/domains/router.py index 521853d..3a87b46 100644 --- a/src/domains/router.py +++ b/src/domains/router.py @@ -7,9 +7,9 @@ main.py only includes this root_router. from fastapi import APIRouter from src.domains.health.router import router as health_router +from src.domains.agents.router import router as agents_router # from src.domains.auth.router import router as auth_router -# from src.domains.agents.router import router as agents_router # from src.domains.tools.router import router as tools_router root_router = APIRouter() @@ -17,11 +17,11 @@ root_router = APIRouter() # Health (no prefix - root level) root_router.include_router(health_router) +# Agents domain (prefix defined in router) +root_router.include_router(agents_router) + # Auth domain # root_router.include_router(auth_router, prefix="/auth", tags=["Auth"]) -# Agents domain -# root_router.include_router(agents_router, prefix="/agents", tags=["Agents"]) - # Tools domain # root_router.include_router(tools_router, prefix="/tools", tags=["Tools"]) diff --git a/src/domains/tools/__init__.py b/src/domains/tools/__init__.py index e69de29..7ca5c3c 100644 --- a/src/domains/tools/__init__.py +++ b/src/domains/tools/__init__.py @@ -0,0 +1,18 @@ +""" +Tool implementations for agent use. + +All tools inherit from BaseTool and return ToolResult. +""" +from src.domains.tools.base import BaseTool, ToolResult +from src.domains.tools.file import ReadFileTool, GlobFilesTool +from src.domains.tools.search import GrepContentTool +from src.domains.tools.shell import BashReadOnlyTool + +__all__ = [ + "BaseTool", + "ToolResult", + "ReadFileTool", + "GlobFilesTool", + "GrepContentTool", + "BashReadOnlyTool", +] diff --git a/src/domains/tools/base.py b/src/domains/tools/base.py new file mode 100644 index 0000000..4577d76 --- /dev/null +++ b/src/domains/tools/base.py @@ -0,0 +1,134 @@ +""" +Base classes for tool implementations. + +All tools inherit from BaseTool and return ToolResult for consistent handling. +""" +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class ToolResult: + """ + Standardized result from tool execution. + + All tools return this for consistent error handling and LLM consumption. + """ + success: bool + data: Any + error: str | None = None + truncated: bool = False + metadata: dict[str, Any] = field(default_factory=dict) + + def to_string(self, max_length: int = 30000) -> str: + """ + Convert result to string for LLM consumption. + + Args: + max_length: Maximum string length before truncation + """ + if not self.success: + return f"ERROR: {self.error}" + + if isinstance(self.data, str): + content = self.data + else: + content = str(self.data) + + if len(content) > max_length: + self.truncated = True + content = content[:max_length] + "\n... (truncated)" + + if self.truncated: + content += "\n[Output was truncated]" + + return content + + def __str__(self) -> str: + return self.to_string() + + +class BaseTool(ABC): + """ + Abstract base class for all tools. + + All domain tools (file, shell, search) inherit from this and implement execute(). + + Usage: + class MyTool(BaseTool): + name = "my_tool" + description = "Does something useful" + + async def execute(self, **kwargs) -> ToolResult: + return ToolResult(success=True, data="result") + """ + + @property + @abstractmethod + def name(self) -> str: + """Tool name for registration and identification.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """ + Tool description for LLM. + + Should include: + - What the tool does + - Arguments and their types + - Return value description + - Usage constraints/examples + """ + pass + + @abstractmethod + async def execute(self, **kwargs: Any) -> ToolResult: + """ + Execute the tool with given arguments. + + Returns: + ToolResult with success status and data or error + """ + pass + + def _validate_path(self, path: str | Path, allowed_paths: list[str]) -> bool: + """ + Validate that a path is within allowed directories. + + Args: + path: Path to validate + allowed_paths: List of allowed directory prefixes + + Returns: + True if path is allowed, False otherwise + """ + if not allowed_paths: + return True # No restrictions when allowed_paths is empty + + resolved = Path(path).resolve() + return any( + str(resolved).startswith(str(Path(allowed).resolve())) + for allowed in allowed_paths + ) + + def _error(self, message: str) -> ToolResult: + """Create an error result.""" + return ToolResult(success=False, data=None, error=message) + + def _success( + self, + data: Any, + truncated: bool = False, + **metadata: Any + ) -> ToolResult: + """Create a success result.""" + return ToolResult( + success=True, + data=data, + truncated=truncated, + metadata=metadata + ) diff --git a/src/domains/tools/file/__init__.py b/src/domains/tools/file/__init__.py index e69de29..4cfd384 100644 --- a/src/domains/tools/file/__init__.py +++ b/src/domains/tools/file/__init__.py @@ -0,0 +1,7 @@ +""" +File operation tools. +""" +from src.domains.tools.file.read import ReadFileTool +from src.domains.tools.file.glob import GlobFilesTool + +__all__ = ["ReadFileTool", "GlobFilesTool"] diff --git a/src/domains/tools/file/glob.py b/src/domains/tools/file/glob.py new file mode 100644 index 0000000..cf92a91 --- /dev/null +++ b/src/domains/tools/file/glob.py @@ -0,0 +1,135 @@ +""" +File glob/pattern matching tool. +""" +import os +from pathlib import Path + +from src.domains.tools.base import BaseTool, ToolResult +from src.shared.logging import logged, get_logger + +logger = get_logger(__name__) + + +class GlobFilesTool(BaseTool): + """ + Find files matching a glob pattern. + + Returns files sorted by modification time (newest first). + """ + + name = "glob_files" + description = """Find files matching a glob pattern. + +Args: + pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md") + path: Directory to search in (default: working directory) + limit: Maximum number of files to return (default: 100) + +Returns: + List of matching absolute file paths, sorted by modification time (newest first). + Returns error if path not found or not allowed. + +Examples: + - "**/*.py" - All Python files recursively + - "src/**/*.ts" - TypeScript files in src + - "*.md" - Markdown files in current directory only + - "**/test_*.py" - All test files + +IMPORTANT: +- Use this tool to find files before reading them +- Never guess file locations - use glob to discover +""" + + def __init__( + self, + allowed_paths: list[str] | None = None, + max_results: int = 100 + ): + """ + Initialize GlobFilesTool. + + Args: + allowed_paths: List of allowed directory prefixes + max_results: Maximum files to return + """ + self.allowed_paths = allowed_paths or [] + self.max_results = max_results + + @logged() + async def execute( + self, + pattern: str, + path: str | None = None, + limit: int | None = None + ) -> ToolResult: + """ + Find files matching glob pattern. + + Args: + pattern: Glob pattern to match + path: Directory to search (default: current directory) + limit: Maximum results to return + + Returns: + ToolResult with list of matching file paths + """ + limit = limit or self.max_results + search_path = Path(path) if path else Path.cwd() + + # Validate search path is allowed + if not self._validate_path(search_path, self.allowed_paths): + return self._error(f"Path not in allowed paths: {search_path}") + + if not search_path.exists(): + return self._error(f"Directory not found: {search_path}") + + if not search_path.is_dir(): + return self._error(f"Not a directory: {search_path}") + + try: + # Find matching files + matches = list(search_path.glob(pattern)) + + # Filter to files only (exclude directories) + files = [f for f in matches if f.is_file()] + + # Validate each result is in allowed paths + if self.allowed_paths: + files = [f for f in files if self._validate_path(f, self.allowed_paths)] + + # Sort by modification time (newest first) + files_with_mtime = [] + for f in files: + try: + mtime = os.path.getmtime(f) + files_with_mtime.append((f, mtime)) + except OSError: + # Skip files we can't stat + continue + + files_with_mtime.sort(key=lambda x: x[1], reverse=True) + sorted_files = [f for f, _ in files_with_mtime] + + # Apply limit + truncated = len(sorted_files) > limit + result_files = sorted_files[:limit] + + # Format output as absolute paths + output_lines = [str(f.resolve()) for f in result_files] + result = "\n".join(output_lines) + + if not output_lines: + result = f"No files found matching '{pattern}' in {search_path}" + + return self._success( + data=result, + truncated=truncated, + total_matches=len(sorted_files), + returned=len(result_files) + ) + + except PermissionError: + return self._error(f"Permission denied: {search_path}") + except Exception as e: + logger.exception(f"Error globbing: {pattern} in {search_path}") + return self._error(f"Error searching files: {e}") diff --git a/src/domains/tools/file/read.py b/src/domains/tools/file/read.py new file mode 100644 index 0000000..80c401f --- /dev/null +++ b/src/domains/tools/file/read.py @@ -0,0 +1,127 @@ +""" +File reading tool with line number formatting and sandboxing. +""" +import aiofiles +from pathlib import Path + +from src.domains.tools.base import BaseTool, ToolResult +from src.shared.logging import logged, get_logger + +logger = get_logger(__name__) + + +class ReadFileTool(BaseTool): + """ + Read file contents with line numbers. + + Supports offset and limit for handling large files. + Returns content in a format similar to `cat -n`. + """ + + name = "read_file" + description = """Read contents of a file with line numbers. + +Args: + file_path: Absolute path to the file to read + offset: Line number to start from (0-based, default: 0) + limit: Maximum number of lines to read (default: 2000) + +Returns: + File contents with line numbers in format " 123| content" + Returns error if file not found or path not allowed. + +IMPORTANT: +- Always use absolute paths +- Never estimate file contents - use this tool to verify +- Check if truncated flag is set for large files +""" + + def __init__( + self, + allowed_paths: list[str] | None = None, + max_lines: int = 2000, + max_line_length: int = 2000 + ): + """ + Initialize ReadFileTool. + + Args: + allowed_paths: List of allowed directory prefixes (empty = no restrictions) + max_lines: Default maximum lines to read + max_line_length: Maximum characters per line before truncation + """ + self.allowed_paths = allowed_paths or [] + self.max_lines = max_lines + self.max_line_length = max_line_length + + @logged() + async def execute( + self, + file_path: str, + offset: int = 0, + limit: int | None = None + ) -> ToolResult: + """ + Read file contents with line numbers. + + Args: + file_path: Absolute path to the file + offset: Starting line (0-based) + limit: Maximum lines to return + + Returns: + ToolResult with formatted file contents or error + """ + limit = limit or self.max_lines + path = Path(file_path) + + # Validate path is allowed + if not self._validate_path(path, self.allowed_paths): + return self._error(f"Path not in allowed paths: {file_path}") + + # Check file exists + if not path.exists(): + return self._error(f"File not found: {file_path}") + + if not path.is_file(): + return self._error(f"Not a file: {file_path}") + + try: + async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f: + content = await f.read() + + lines = content.splitlines() + total_lines = len(lines) + + # Apply offset and limit + selected = lines[offset:offset + limit] + truncated = total_lines > offset + limit + + # Format with line numbers (right-aligned, 6 chars) + numbered_lines = [] + for i, line in enumerate(selected): + line_num = offset + i + 1 # 1-based for display + + # Truncate long lines + if len(line) > self.max_line_length: + line = line[:self.max_line_length] + "..." + + numbered_lines.append(f"{line_num:>6}| {line}") + + result = "\n".join(numbered_lines) + + return self._success( + data=result, + truncated=truncated, + total_lines=total_lines, + lines_returned=len(selected), + offset=offset + ) + + except PermissionError: + return self._error(f"Permission denied: {file_path}") + except UnicodeDecodeError as e: + return self._error(f"Unable to decode file (not text?): {e}") + except Exception as e: + logger.exception(f"Error reading file: {file_path}") + return self._error(f"Error reading file: {e}") diff --git a/src/domains/tools/search/__init__.py b/src/domains/tools/search/__init__.py index e69de29..1050da9 100644 --- a/src/domains/tools/search/__init__.py +++ b/src/domains/tools/search/__init__.py @@ -0,0 +1,6 @@ +""" +Search tools. +""" +from src.domains.tools.search.grep import GrepContentTool + +__all__ = ["GrepContentTool"] diff --git a/src/domains/tools/search/grep.py b/src/domains/tools/search/grep.py new file mode 100644 index 0000000..e602393 --- /dev/null +++ b/src/domains/tools/search/grep.py @@ -0,0 +1,236 @@ +""" +Content search tool using regex patterns. +""" +import re +from pathlib import Path +from typing import Literal + +from src.domains.tools.base import BaseTool, ToolResult +from src.shared.logging import logged, get_logger + +logger = get_logger(__name__) + + +class GrepContentTool(BaseTool): + """ + Search file contents using regex patterns. + + Similar to grep/ripgrep but implemented in Python for portability. + """ + + name = "grep_content" + description = """Search file contents using regex pattern. + +Args: + pattern: Regex pattern to search for (Python re syntax) + path: Directory or file to search (default: working directory) + file_glob: Filter files by glob pattern (e.g., "*.py", "*.ts") + context_lines: Lines of context before/after matches (default: 0) + case_sensitive: Whether search is case-sensitive (default: True) + output_mode: "content" for matching lines, "files" for file paths only + +Returns: + Matching lines with file paths and line numbers, or list of files. + Format: "filepath:line_num: content" + +Examples: + - pattern="def.*init" file_glob="*.py" - Find init methods in Python files + - pattern="TODO" - Find all TODO comments + - pattern="class\\s+\\w+" - Find class definitions + +IMPORTANT: +- Use this tool to search for code patterns +- Escape special regex characters (\\, ., *, etc.) +- Never guess where code is - use grep to find it +""" + + def __init__( + self, + allowed_paths: list[str] | None = None, + max_results: int = 100, + max_file_size: int = 1_000_000 # 1MB + ): + """ + Initialize GrepContentTool. + + Args: + allowed_paths: List of allowed directory prefixes + max_results: Maximum matches to return + max_file_size: Skip files larger than this (bytes) + """ + self.allowed_paths = allowed_paths or [] + self.max_results = max_results + self.max_file_size = max_file_size + + @logged() + async def execute( + self, + pattern: str, + path: str | None = None, + file_glob: str | None = None, + context_lines: int = 0, + case_sensitive: bool = True, + output_mode: Literal["content", "files"] = "content" + ) -> ToolResult: + """ + Search for pattern in files. + + Args: + pattern: Regex pattern to search + path: Directory or file to search + file_glob: Filter to files matching glob + context_lines: Context lines around matches + case_sensitive: Case-sensitive search + output_mode: "content" or "files" + + Returns: + ToolResult with matching content or file list + """ + search_path = Path(path) if path else Path.cwd() + + # Validate path + if not self._validate_path(search_path, self.allowed_paths): + return self._error(f"Path not in allowed paths: {search_path}") + + if not search_path.exists(): + return self._error(f"Path not found: {search_path}") + + # Compile regex + try: + flags = 0 if case_sensitive else re.IGNORECASE + regex = re.compile(pattern, flags) + except re.error as e: + return self._error(f"Invalid regex pattern: {e}") + + # Collect files to search + if search_path.is_file(): + files_to_search = [search_path] + else: + glob_pattern = file_glob or "**/*" + files_to_search = [ + f for f in search_path.glob(glob_pattern) + if f.is_file() + ] + + # Filter by allowed paths + if self.allowed_paths: + files_to_search = [ + f for f in files_to_search + if self._validate_path(f, self.allowed_paths) + ] + + # Search files + matches = [] + files_with_matches = set() + total_matches = 0 + + for file_path in files_to_search: + # Skip large files + try: + if file_path.stat().st_size > self.max_file_size: + continue + except OSError: + continue + + # Skip binary files (heuristic) + if self._is_likely_binary(file_path): + continue + + file_matches = await self._search_file( + file_path, regex, context_lines + ) + + if file_matches: + files_with_matches.add(str(file_path.resolve())) + total_matches += len(file_matches) + matches.extend(file_matches) + + # Check result limit + if len(matches) >= self.max_results: + break + + # Format output + truncated = total_matches > self.max_results + + if output_mode == "files": + result = "\n".join(sorted(files_with_matches)) + if not result: + result = f"No files found matching pattern '{pattern}'" + else: + result = "\n".join(matches[:self.max_results]) + if not result: + result = f"No matches found for pattern '{pattern}'" + + return self._success( + data=result, + truncated=truncated, + total_matches=total_matches, + files_matched=len(files_with_matches) + ) + + async def _search_file( + self, + file_path: Path, + regex: re.Pattern, + context_lines: int + ) -> list[str]: + """Search a single file for matches.""" + try: + content = file_path.read_text(encoding='utf-8', errors='replace') + lines = content.splitlines() + except (PermissionError, UnicodeDecodeError, OSError): + return [] + + matches = [] + matched_line_nums = set() + + # Find all matching lines + for i, line in enumerate(lines): + if regex.search(line): + matched_line_nums.add(i) + + # Add context and format + for match_num in sorted(matched_line_nums): + start = max(0, match_num - context_lines) + end = min(len(lines), match_num + context_lines + 1) + + for i in range(start, end): + prefix = ">" if i == match_num else " " + line_num = i + 1 # 1-based + formatted = f"{file_path}:{line_num}:{prefix} {lines[i]}" + matches.append(formatted) + + # Add separator between match groups + if context_lines > 0: + matches.append("--") + + # Remove trailing separator + if matches and matches[-1] == "--": + matches.pop() + + return matches + + def _is_likely_binary(self, file_path: Path) -> bool: + """Check if file is likely binary based on extension or content.""" + binary_extensions = { + '.pyc', '.pyo', '.so', '.dll', '.exe', '.bin', + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', + '.pdf', '.zip', '.tar', '.gz', '.bz2', '.xz', + '.woff', '.woff2', '.ttf', '.eot', + '.mp3', '.mp4', '.wav', '.avi', '.mov', + '.db', '.sqlite', '.sqlite3', + } + + if file_path.suffix.lower() in binary_extensions: + return True + + # Check first bytes for null characters + try: + with open(file_path, 'rb') as f: + chunk = f.read(1024) + if b'\x00' in chunk: + return True + except (PermissionError, OSError): + return True + + return False diff --git a/src/domains/tools/shell/__init__.py b/src/domains/tools/shell/__init__.py index e69de29..0b2118f 100644 --- a/src/domains/tools/shell/__init__.py +++ b/src/domains/tools/shell/__init__.py @@ -0,0 +1,6 @@ +""" +Shell execution tools. +""" +from src.domains.tools.shell.bash import BashReadOnlyTool + +__all__ = ["BashReadOnlyTool"] diff --git a/src/domains/tools/shell/bash.py b/src/domains/tools/shell/bash.py new file mode 100644 index 0000000..4651ad5 --- /dev/null +++ b/src/domains/tools/shell/bash.py @@ -0,0 +1,236 @@ +""" +Read-only bash command execution tool. + +Only allows safe, read-only commands to prevent accidental damage. +""" +import asyncio +import shlex +from pathlib import Path + +from src.domains.tools.base import BaseTool, ToolResult +from src.shared.logging import logged, get_logger + +logger = get_logger(__name__) + + +# Commands that are allowed in read-only mode +ALLOWED_COMMANDS = { + # File inspection + "ls", "find", "cat", "head", "tail", "wc", "file", "stat", + "tree", "du", "df", + # Text processing (read-only) + "grep", "awk", "sed", "sort", "uniq", "cut", "tr", + # Git (read-only operations) + "git", + # System info + "pwd", "whoami", "hostname", "uname", "date", "env", "printenv", + "which", "type", "echo", + # Archive inspection + "tar", "unzip", "zipinfo", +} + +# Git subcommands that are allowed (read-only) +ALLOWED_GIT_SUBCOMMANDS = { + "status", "log", "diff", "show", "branch", "tag", + "remote", "config", "ls-files", "ls-tree", + "rev-parse", "describe", "shortlog", "blame", +} + +# Patterns that are never allowed (security) +FORBIDDEN_PATTERNS = [ + # Destructive redirects + ">", ">>", + # Command chaining (could bypass checks) + "&&", "||", ";", + # Subshells + "$(", "`", + # Explicit destructive commands + "rm ", "rm\t", "rmdir", + "mv ", "mv\t", + "cp ", "cp\t", + "mkdir", "touch", + # Package managers + "pip", "npm", "yarn", "apt", "yum", "brew", + # Network + "curl", "wget", "ssh", "scp", + # Process control + "kill", "pkill", "killall", +] + + +class BashReadOnlyTool(BaseTool): + """ + Execute read-only bash commands safely. + + Only allows a curated set of commands that cannot modify the filesystem. + """ + + name = "bash_readonly" + description = """Execute a read-only bash command. + +ALLOWED commands: +- File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du +- Git (read-only): git status, git log, git diff, git show, git branch +- Text processing: grep, awk, sed (read-only), sort, uniq, cut +- System info: pwd, whoami, hostname, uname, date, which + +FORBIDDEN: +- Any file modification (rm, mv, cp, mkdir, touch) +- Redirects (>, >>) +- Command chaining (&&, ||, ;) +- Package managers (pip, npm, apt) +- Network commands (curl, wget, ssh) + +Args: + command: The bash command to execute + cwd: Working directory for the command (default: current directory) + timeout: Timeout in seconds (default: 30) + +Returns: + Command stdout on success, or error message. + +Examples: + - "ls -la" - List files with details + - "git status" - Show git status + - "find . -name '*.py' -type f" - Find Python files + - "head -50 README.md" - First 50 lines of README +""" + + def __init__( + self, + allowed_paths: list[str] | None = None, + default_timeout: int = 30, + max_output_size: int = 50000 + ): + """ + Initialize BashReadOnlyTool. + + Args: + allowed_paths: Allowed working directories + default_timeout: Default command timeout in seconds + max_output_size: Maximum output size in characters + """ + self.allowed_paths = allowed_paths or [] + self.default_timeout = default_timeout + self.max_output_size = max_output_size + + @logged() + async def execute( + self, + command: str, + cwd: str | None = None, + timeout: int | None = None + ) -> ToolResult: + """ + Execute a read-only bash command. + + Args: + command: Command to execute + cwd: Working directory + timeout: Timeout in seconds + + Returns: + ToolResult with command output or error + """ + timeout = timeout or self.default_timeout + working_dir = Path(cwd) if cwd else Path.cwd() + + # Validate working directory + if not self._validate_path(working_dir, self.allowed_paths): + return self._error(f"Working directory not allowed: {working_dir}") + + if not working_dir.exists(): + return self._error(f"Working directory not found: {working_dir}") + + # Security validation + validation_error = self._validate_command(command) + if validation_error: + return self._error(validation_error) + + try: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(working_dir) + ) + + stdout, stderr = await asyncio.wait_for( + proc.communicate(), + timeout=timeout + ) + + stdout_str = stdout.decode('utf-8', errors='replace') + stderr_str = stderr.decode('utf-8', errors='replace') + + # Truncate if necessary + truncated = False + if len(stdout_str) > self.max_output_size: + stdout_str = stdout_str[:self.max_output_size] + truncated = True + + if proc.returncode != 0: + # Command failed, return stderr + error_msg = stderr_str or f"Command exited with code {proc.returncode}" + return ToolResult( + success=False, + data=stdout_str if stdout_str else None, + error=error_msg, + truncated=truncated + ) + + # Success - combine stdout and stderr if both present + output = stdout_str + if stderr_str and not output: + output = stderr_str + + return self._success( + data=output, + truncated=truncated, + exit_code=proc.returncode + ) + + except asyncio.TimeoutError: + return self._error(f"Command timed out after {timeout} seconds") + except Exception as e: + logger.exception(f"Error executing command: {command}") + return self._error(f"Error executing command: {e}") + + def _validate_command(self, command: str) -> str | None: + """ + Validate command is safe to execute. + + Returns: + Error message if invalid, None if valid + """ + # Check for forbidden patterns + command_lower = command.lower() + for pattern in FORBIDDEN_PATTERNS: + if pattern in command_lower: + return f"Command contains forbidden pattern: {pattern.strip()}" + + # Parse command to get base command + try: + tokens = shlex.split(command) + if not tokens: + return "Empty command" + except ValueError as e: + return f"Invalid command syntax: {e}" + + # Get base command (handle full paths) + base_cmd = Path(tokens[0]).name + + # Check if command is allowed + if base_cmd not in ALLOWED_COMMANDS: + return f"Command not allowed in read-only mode: {base_cmd}" + + # Special handling for git - check subcommand + if base_cmd == "git": + if len(tokens) < 2: + return "Git command requires a subcommand" + + git_subcommand = tokens[1] + if git_subcommand not in ALLOWED_GIT_SUBCOMMANDS: + return f"Git subcommand not allowed: {git_subcommand}" + + return None diff --git a/src/ollama/__init__.py b/src/ollama/__init__.py new file mode 100644 index 0000000..8bb8a14 --- /dev/null +++ b/src/ollama/__init__.py @@ -0,0 +1,4 @@ +"""Ollama integration for Webber.""" +from src.ollama.provider import WebberOllamaProvider, get_ollama_provider + +__all__ = ["WebberOllamaProvider", "get_ollama_provider"] diff --git a/src/ollama/provider.py b/src/ollama/provider.py new file mode 100644 index 0000000..e588bb4 --- /dev/null +++ b/src/ollama/provider.py @@ -0,0 +1,132 @@ +""" +PydanticAI provider for Ollama with message sanitization. + +Ollama's OpenAI-compatible API rejects messages with `content: null`, +which PydanticAI sends for assistant messages that only contain tool calls. +This provider sanitizes messages to use empty strings instead of null. + +Ported from tatlock project. +""" +from typing import Any + +from openai import AsyncOpenAI +from pydantic_ai.providers.ollama import OllamaProvider + +from src.shared.config import get_settings +from src.shared.logging import get_logger + +logger = get_logger(__name__) + + +class WebberOllamaProvider(OllamaProvider): + """ + Custom OllamaProvider with message sanitization for Webber agents. + + Fixes the 'invalid message content type: ' error that occurs + when assistant messages have `content: null` with tool calls. + """ + + def __init__(self, base_url: str | None = None): + """ + Initialize provider with Ollama base URL. + + Args: + base_url: Ollama API URL (defaults to settings.ollama_url/v1) + """ + if base_url is None: + settings = get_settings() + clean_host = settings.ollama_url.rstrip("/") + base_url = f"{clean_host}/v1" + + super().__init__(base_url=base_url) + + # Override the client with our sanitized version + self._openai_client = _SanitizedAsyncOpenAI(base_url=base_url) + + logger.debug(f"WebberOllamaProvider created with base_url={base_url}") + + +class _SanitizedAsyncOpenAI(AsyncOpenAI): + """AsyncOpenAI client that sanitizes messages before sending.""" + + def __init__(self, **kwargs: Any): + # Ollama doesn't need an API key + super().__init__(api_key="ollama", **kwargs) + + @property + def chat(self) -> "_SanitizedChat": + """Return sanitized chat interface.""" + return _SanitizedChat(self) + + +class _SanitizedChat: + """Chat interface wrapper with sanitized completions.""" + + def __init__(self, client: _SanitizedAsyncOpenAI): + self._client = client + self._original_chat = AsyncOpenAI.chat.fget(client) # type: ignore + + @property + def completions(self) -> "_SanitizedCompletions": + """Return sanitized completions interface.""" + return _SanitizedCompletions(self._original_chat.completions) + + +class _SanitizedCompletions: + """Completions wrapper that sanitizes messages before API calls.""" + + def __init__(self, original_completions: Any): + self._original = original_completions + + async def create(self, **kwargs: Any) -> Any: + """ + Create chat completion with sanitized messages. + + Converts `content: null` to `content: ""` in assistant messages + to prevent Ollama's 'invalid message content type: ' error. + """ + if "messages" in kwargs: + kwargs["messages"] = _sanitize_messages(kwargs["messages"]) + + return await self._original.create(**kwargs) + + +def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Sanitize messages to fix null content issues. + + When an assistant message has tool_calls but no text content, + PydanticAI sets content to None. Ollama rejects this. + We convert None to empty string. + + Args: + messages: List of chat messages + + Returns: + Sanitized messages with null content replaced by empty strings + """ + sanitized = [] + for msg in messages: + msg_copy = dict(msg) + + # Fix null content in assistant messages with tool calls + if msg_copy.get("role") == "assistant": + if msg_copy.get("content") is None and msg_copy.get("tool_calls"): + msg_copy["content"] = "" + logger.debug( + f"Sanitized null content, tool_calls={len(msg_copy['tool_calls'])}" + ) + + sanitized.append(msg_copy) + + return sanitized + + +def get_ollama_provider() -> WebberOllamaProvider: + """ + Get a configured Ollama provider for PydanticAI agents. + + Returns: + WebberOllamaProvider configured with sanitization + """ + return WebberOllamaProvider() diff --git a/tests/test_agents_api.py b/tests/test_agents_api.py new file mode 100644 index 0000000..dd378bb --- /dev/null +++ b/tests/test_agents_api.py @@ -0,0 +1,75 @@ +""" +Tests for agent REST API endpoints. +""" +import pytest + + +class TestAgentListEndpoint: + """Tests for GET /agents/ endpoint.""" + + @pytest.mark.anyio + async def test_list_agents(self, auth_client): + """Test listing available agents.""" + response = await auth_client.get("/agents/") + + assert response.status_code == 200 + data = response.json() + assert "agents" in data + assert len(data["agents"]) >= 1 + + # Check explore agent is present + agent_names = [a["name"] for a in data["agents"]] + assert "explore" in agent_names + + +class TestAgentInfoEndpoint: + """Tests for GET /agents/{agent_type} endpoint.""" + + @pytest.mark.anyio + async def test_get_explore_agent_info(self, auth_client): + """Test getting explore agent info.""" + response = await auth_client.get("/agents/explore") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "explore" + assert "description" in data + + @pytest.mark.anyio + async def test_get_unknown_agent(self, auth_client): + """Test getting info for unknown agent.""" + response = await auth_client.get("/agents/nonexistent") + + assert response.status_code == 404 + + +class TestAgentRunEndpoint: + """Tests for POST /agents/run endpoint.""" + + @pytest.mark.anyio + async def test_run_with_unknown_agent(self, auth_client): + """Test running unknown agent type.""" + response = await auth_client.post( + "/agents/run", + json={ + "prompt": "test", + "agent_type": "nonexistent", + "working_dir": "." + } + ) + + assert response.status_code == 400 + assert "Unknown agent" in response.json()["detail"] + + @pytest.mark.anyio + async def test_run_request_validation(self, auth_client): + """Test request validation.""" + # Missing required field + response = await auth_client.post( + "/agents/run", + json={ + "working_dir": "." + } + ) + + assert response.status_code == 422 # Validation error diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..b26b159 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,234 @@ +""" +Tests for tool implementations. +""" +import tempfile +from pathlib import Path + +import pytest + +from src.domains.tools.file.read import ReadFileTool +from src.domains.tools.file.glob import GlobFilesTool +from src.domains.tools.search.grep import GrepContentTool +from src.domains.tools.shell.bash import BashReadOnlyTool + + +class TestReadFileTool: + """Tests for ReadFileTool.""" + + @pytest.fixture + def tool(self): + return ReadFileTool() + + @pytest.fixture + def temp_file(self): + """Create a temporary file with content.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + for i in range(100): + f.write(f"Line {i + 1}: This is test content\n") + f.flush() + yield Path(f.name) + Path(f.name).unlink(missing_ok=True) + + @pytest.mark.anyio + async def test_read_file_success(self, tool, temp_file): + """Test reading a file successfully.""" + result = await tool.execute(file_path=str(temp_file)) + + assert result.success + assert "Line 1:" in result.data + assert result.metadata.get("total_lines") == 100 + + @pytest.mark.anyio + async def test_read_file_with_offset(self, tool, temp_file): + """Test reading with offset.""" + result = await tool.execute(file_path=str(temp_file), offset=10, limit=5) + + assert result.success + assert "Line 11:" in result.data + assert result.metadata.get("lines_returned") == 5 + + @pytest.mark.anyio + async def test_read_file_not_found(self, tool): + """Test reading non-existent file.""" + result = await tool.execute(file_path="/nonexistent/file.txt") + + assert not result.success + assert "not found" in result.error.lower() + + @pytest.mark.anyio + async def test_read_file_path_restriction(self, temp_file): + """Test path restriction enforcement.""" + tool = ReadFileTool(allowed_paths=["/some/other/path"]) + result = await tool.execute(file_path=str(temp_file)) + + assert not result.success + assert "not in allowed" in result.error.lower() + + +class TestGlobFilesTool: + """Tests for GlobFilesTool.""" + + @pytest.fixture + def tool(self): + return GlobFilesTool() + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory with files.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) + # Create test files + (path / "file1.py").write_text("# Python file 1") + (path / "file2.py").write_text("# Python file 2") + (path / "readme.md").write_text("# Readme") + (path / "subdir").mkdir() + (path / "subdir" / "nested.py").write_text("# Nested") + yield path + + @pytest.mark.anyio + async def test_glob_python_files(self, tool, temp_dir): + """Test finding Python files.""" + result = await tool.execute(pattern="**/*.py", path=str(temp_dir)) + + assert result.success + assert "file1.py" in result.data + assert "file2.py" in result.data + assert "nested.py" in result.data + + @pytest.mark.anyio + async def test_glob_with_limit(self, tool, temp_dir): + """Test result limiting.""" + result = await tool.execute(pattern="**/*.py", path=str(temp_dir), limit=2) + + assert result.success + assert result.metadata.get("returned") == 2 + + @pytest.mark.anyio + async def test_glob_no_matches(self, tool, temp_dir): + """Test when no files match.""" + result = await tool.execute(pattern="**/*.xyz", path=str(temp_dir)) + + assert result.success + assert "No files found" in result.data + + +class TestGrepContentTool: + """Tests for GrepContentTool.""" + + @pytest.fixture + def tool(self): + return GrepContentTool() + + @pytest.fixture + def temp_dir(self): + """Create a temporary directory with searchable content.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) + (path / "code.py").write_text(""" +def hello_world(): + print("Hello, World!") + +def goodbye_world(): + print("Goodbye!") +""") + (path / "config.py").write_text(""" +DEBUG = True +API_KEY = "secret" +""") + yield path + + @pytest.mark.anyio + async def test_grep_pattern(self, tool, temp_dir): + """Test searching for a pattern.""" + result = await tool.execute(pattern="def.*world", path=str(temp_dir)) + + assert result.success + assert "hello_world" in result.data + assert "goodbye_world" in result.data + + @pytest.mark.anyio + async def test_grep_case_insensitive(self, tool, temp_dir): + """Test case-insensitive search.""" + result = await tool.execute( + pattern="DEBUG", + path=str(temp_dir), + case_sensitive=False + ) + + assert result.success + assert "DEBUG" in result.data + + @pytest.mark.anyio + async def test_grep_with_file_glob(self, tool, temp_dir): + """Test filtering by file glob.""" + result = await tool.execute( + pattern="=", + path=str(temp_dir), + file_glob="config.py" + ) + + assert result.success + assert "config.py" in result.data + + +class TestBashReadOnlyTool: + """Tests for BashReadOnlyTool.""" + + @pytest.fixture + def tool(self): + return BashReadOnlyTool() + + @pytest.mark.anyio + async def test_ls_command(self, tool): + """Test allowed ls command.""" + result = await tool.execute(command="ls -la", cwd="/tmp") + + assert result.success + + @pytest.mark.anyio + async def test_pwd_command(self, tool): + """Test allowed pwd command.""" + result = await tool.execute(command="pwd", cwd="/tmp") + + assert result.success + assert "/tmp" in result.data + + @pytest.mark.anyio + async def test_forbidden_rm_command(self, tool): + """Test that rm is blocked.""" + result = await tool.execute(command="rm -rf /tmp/test") + + assert not result.success + assert "forbidden" in result.error.lower() or "not allowed" in result.error.lower() + + @pytest.mark.anyio + async def test_forbidden_redirect(self, tool): + """Test that redirects are blocked.""" + result = await tool.execute(command="echo test > /tmp/file") + + assert not result.success + assert "forbidden" in result.error.lower() + + @pytest.mark.anyio + async def test_forbidden_chaining(self, tool): + """Test that command chaining is blocked.""" + result = await tool.execute(command="ls && rm -rf /") + + assert not result.success + + @pytest.mark.anyio + async def test_git_status(self, tool, tmp_path): + """Test git status on non-git directory.""" + result = await tool.execute(command="git status", cwd=str(tmp_path)) + + # Should fail but not because command is forbidden + assert not result.success + assert "not a git repository" in result.error.lower() or "fatal" in result.data.lower() + + @pytest.mark.anyio + async def test_forbidden_curl(self, tool): + """Test that curl is blocked.""" + result = await tool.execute(command="curl http://example.com") + + assert not result.success + assert "forbidden" in result.error.lower() or "not allowed" in result.error.lower() diff --git a/wakeup.sh b/wakeup.sh index 9c49a3e..0e17bfb 100755 --- a/wakeup.sh +++ b/wakeup.sh @@ -11,11 +11,14 @@ NC='\033[0m' # No Color echo -e "${GREEN}Starting Webber...${NC}" -# Check if port 8086 is already in use -if lsof -Pi :8086 -sTCP:LISTEN -t >/dev/null 2>&1 ; then - echo -e "${RED}Error: Port 8086 is already in use${NC}" - echo "Run: lsof -i :8086 to see what's using it" - echo "Or run: kill \$(lsof -t -i:8086) to stop it" +# Development port (8095) - Production uses 8086 in Docker +DEV_PORT=8095 + +# Check if dev port is already in use +if lsof -Pi :$DEV_PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then + echo -e "${RED}Error: Port $DEV_PORT is already in use${NC}" + echo "Run: lsof -i :$DEV_PORT to see what's using it" + echo "Or run: kill \$(lsof -t -i:$DEV_PORT) to stop it" exit 1 fi @@ -41,8 +44,9 @@ LOG_FILE="$LOGS_DIR/server.log" echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}" # Start the server -echo -e "${GREEN}Starting uvicorn server on http://localhost:8086${NC}" +echo -e "${GREEN}Starting uvicorn server on http://localhost:$DEV_PORT${NC}" +echo -e "${YELLOW}Production is on :8086, dev is on :$DEV_PORT${NC}" echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}" echo "" -uvicorn src.main:app --reload --host 0.0.0.0 --port 8086 2>&1 | tee "$LOG_FILE" +uvicorn src.main:app --reload --host 0.0.0.0 --port $DEV_PORT 2>&1 | tee "$LOG_FILE"