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 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 01:26:30 +01:00
co-authored by Claude Opus 4.5
parent 667e2ca8e4
commit f4e8552298
45 changed files with 3299 additions and 12 deletions
+12
View File
@@ -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)*
Executable
+14
View File
@@ -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"
+7
View File
@@ -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"
+141
View File
@@ -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()
+261
View File
@@ -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()
+75
View File
@@ -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)
+14
View File
@@ -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
+5 -1
View File
@@ -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"]
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""
Webber CLI - Command-line interface for the multi-agent system.
"""
+6
View File
@@ -0,0 +1,6 @@
"""
CLI commands.
"""
from src.cli.commands import chat, explore, version
__all__ = ["chat", "explore", "version"]
+128
View File
@@ -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()
+83
View File
@@ -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)
+26
View File
@@ -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"))
+64
View File
@@ -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()
+7
View File
@@ -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"]
+60
View File
@@ -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)
+104
View File
@@ -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}[/]")
+70
View File
@@ -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
+7
View File
@@ -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"]
+37
View File
@@ -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}[/]")
+84
View File
@@ -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)
+37
View File
@@ -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",
]
+167
View File
@@ -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()
+16
View File
@@ -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",
]
+127
View File
@@ -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)
+98
View File
@@ -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")
"""
+163
View File
@@ -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()
+84
View File
@@ -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,
)
+30
View File
@@ -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]
+4 -4
View File
@@ -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"])
+18
View File
@@ -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",
]
+134
View File
@@ -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
)
+7
View File
@@ -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"]
+135
View File
@@ -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}")
+127
View File
@@ -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}")
+6
View File
@@ -0,0 +1,6 @@
"""
Search tools.
"""
from src.domains.tools.search.grep import GrepContentTool
__all__ = ["GrepContentTool"]
+236
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
"""
Shell execution tools.
"""
from src.domains.tools.shell.bash import BashReadOnlyTool
__all__ = ["BashReadOnlyTool"]
+236
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""Ollama integration for Webber."""
from src.ollama.provider import WebberOllamaProvider, get_ollama_provider
__all__ = ["WebberOllamaProvider", "get_ollama_provider"]
+132
View File
@@ -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: <nil>' 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: <nil>' 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()
+75
View File
@@ -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
+234
View File
@@ -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()
+11 -7
View File
@@ -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"