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:
@@ -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
|
## 2. FastAPI Architecture & Best Practices
|
||||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -15,13 +15,17 @@ classifiers = [
|
|||||||
"Topic :: Software Development :: Code Generators",
|
"Topic :: Software Development :: Code Generators",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
webber = "src.cli.main:app"
|
||||||
|
webber-cli = "cli.main:app"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=75.0", "wheel"]
|
requires = ["setuptools>=75.0", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["src*"]
|
include = ["src*", "cli*"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ pydantic-ai~=1.40.0
|
|||||||
httpx~=0.28.1
|
httpx~=0.28.1
|
||||||
aiofiles~=25.1.0
|
aiofiles~=25.1.0
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
typer~=0.15.0
|
||||||
|
rich~=13.9.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-multipart~=0.0.21
|
python-multipart~=0.0.21
|
||||||
python-dotenv~=1.2.1
|
python-dotenv~=1.2.1
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Webber CLI - Command-line interface for the multi-agent system.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
CLI commands.
|
||||||
|
"""
|
||||||
|
from src.cli.commands import chat, explore, version
|
||||||
|
|
||||||
|
__all__ = ["chat", "explore", "version"]
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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"))
|
||||||
@@ -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()
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
@@ -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}[/]")
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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}[/]")
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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]
|
||||||
@@ -7,9 +7,9 @@ main.py only includes this root_router.
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.domains.health.router import router as health_router
|
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.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
|
# from src.domains.tools.router import router as tools_router
|
||||||
|
|
||||||
root_router = APIRouter()
|
root_router = APIRouter()
|
||||||
@@ -17,11 +17,11 @@ root_router = APIRouter()
|
|||||||
# Health (no prefix - root level)
|
# Health (no prefix - root level)
|
||||||
root_router.include_router(health_router)
|
root_router.include_router(health_router)
|
||||||
|
|
||||||
|
# Agents domain (prefix defined in router)
|
||||||
|
root_router.include_router(agents_router)
|
||||||
|
|
||||||
# Auth domain
|
# Auth domain
|
||||||
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
# Agents domain
|
|
||||||
# root_router.include_router(agents_router, prefix="/agents", tags=["Agents"])
|
|
||||||
|
|
||||||
# Tools domain
|
# Tools domain
|
||||||
# root_router.include_router(tools_router, prefix="/tools", tags=["Tools"])
|
# root_router.include_router(tools_router, prefix="/tools", tags=["Tools"])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Search tools.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
|
||||||
|
__all__ = ["GrepContentTool"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Shell execution tools.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
|
||||||
|
__all__ = ["BashReadOnlyTool"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Ollama integration for Webber."""
|
||||||
|
from src.ollama.provider import WebberOllamaProvider, get_ollama_provider
|
||||||
|
|
||||||
|
__all__ = ["WebberOllamaProvider", "get_ollama_provider"]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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,11 +11,14 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
echo -e "${GREEN}Starting Webber...${NC}"
|
echo -e "${GREEN}Starting Webber...${NC}"
|
||||||
|
|
||||||
# Check if port 8086 is already in use
|
# Development port (8095) - Production uses 8086 in Docker
|
||||||
if lsof -Pi :8086 -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
DEV_PORT=8095
|
||||||
echo -e "${RED}Error: Port 8086 is already in use${NC}"
|
|
||||||
echo "Run: lsof -i :8086 to see what's using it"
|
# Check if dev port is already in use
|
||||||
echo "Or run: kill \$(lsof -t -i:8086) to stop it"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -41,8 +44,9 @@ LOG_FILE="$LOGS_DIR/server.log"
|
|||||||
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
||||||
|
|
||||||
# Start the server
|
# 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 -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
||||||
echo ""
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user