refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects: - webber-api/: FastAPI backend server with all agent code - webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/) - webber-sandbox/: Test project for functional testing Key changes: - Each subproject has its own .venv (Python 3.12+) - Added sandbox.sh for managing test project templates - Created sandbox-templates/ with calculator-cli and empty starter - Updated CI/CD for prefixed tags (api/v*, cli/v*) - Added comprehensive AGENTS.md with operational instructions - Added gitignore filtering to glob and grep tools - Created pyproject.toml for each subproject Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Webber Configuration
|
||||
# Copy to .env and customize
|
||||
|
||||
# Application
|
||||
DEBUG=true
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8086
|
||||
|
||||
# CORS (comma-separated)
|
||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
|
||||
|
||||
# LLM - Ollama (tower-of-joy)
|
||||
OLLAMA_URL=http://192.168.86.149:11434
|
||||
OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
|
||||
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
||||
|
||||
# Auth - Tatlock integration (optional)
|
||||
# TATLOCK_API_URL=http://192.168.86.149:8000
|
||||
# INTERNAL_API_KEY=your-internal-key
|
||||
|
||||
# Tool execution
|
||||
TOOL_TIMEOUT_SECONDS=120
|
||||
SANDBOX_ENABLED=true
|
||||
# ALLOWED_PATHS=["/home/user/projects","/tmp/webber"]
|
||||
|
||||
# Sessions
|
||||
SESSION_TTL_HOURS=24
|
||||
MAX_CONTEXT_TOKENS=128000
|
||||
@@ -0,0 +1,128 @@
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
||||
|
||||
## 1. Agent Operational Protocols
|
||||
|
||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||
|
||||
### 🛡️ Git Discipline
|
||||
* **ALWAYS add the relevant tests for the added code** Make sure to keep the test coverage up as we go, and run tests before commiting.
|
||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||
* `feat: add user login endpoint`
|
||||
* `fix: resolve database connection timeout`
|
||||
* `refactor: split monolith dependency file`
|
||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||
|
||||
### 📝 Changelog Maintenance
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
|
||||
### 🚀 Release Flow
|
||||
When changes are ready for deployment:
|
||||
|
||||
1. **Ask user if deploy cycle is desired **
|
||||
|
||||
2. **Update version** in `pyproject.toml`:
|
||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
||||
|
||||
3. **Update CHANGELOG.md**:
|
||||
- Move items from `[Unreleased]` to new version section
|
||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: description of changes"
|
||||
git tag v1.8.4
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new version tag (starts with "v")
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8086/health`
|
||||
|
||||
---
|
||||
|
||||
### 🧪 Local Development Setup
|
||||
|
||||
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
|
||||
* **Only deploy** when a phase or feature is complete and tested locally
|
||||
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup
|
||||
|
||||
#### ⚠️ CRITICAL: Starting the Local Server
|
||||
|
||||
**ALWAYS use `./wakeup.sh` to start the local server. NEVER use raw uvicorn commands.**
|
||||
|
||||
```bash
|
||||
./wakeup.sh
|
||||
```
|
||||
|
||||
The wakeup script provides:
|
||||
- **Port conflict detection** - Warns if port 8086 is already in use
|
||||
- **Virtual environment activation** - Ensures correct Python environment
|
||||
- **Centralized logging** - All logs written to `logs/server.log` for easy tailing
|
||||
- **Auto-reload** - Code changes picked up automatically (except requirements.txt changes)
|
||||
- **Consistent configuration** - Same startup every time
|
||||
|
||||
To monitor logs in another terminal:
|
||||
```bash
|
||||
tail -f logs/server.log
|
||||
```
|
||||
|
||||
To stop the server: Press `Ctrl+C`
|
||||
|
||||
To kill a stuck server:
|
||||
```bash
|
||||
pkill -f "uvicorn src.main:app"
|
||||
# or
|
||||
kill $(lsof -t -i:8086)
|
||||
```
|
||||
|
||||
#### Testing
|
||||
|
||||
**Test REST endpoints** against `http://localhost:8086`:
|
||||
```bash
|
||||
curl http://localhost:8086/health
|
||||
curl http://localhost:8086/
|
||||
curl http://localhost:8086/docs # Swagger UI
|
||||
```
|
||||
|
||||
**Running tests**: Always use the venv explicitly to avoid environment mismatches:
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ # All tests
|
||||
.venv/bin/python -m pytest tests/ -v # Verbose output
|
||||
.venv/bin/python -m pytest tests/ --cov # With coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1.5 Known Issues & Future Improvements
|
||||
|
||||
### Explore Agent
|
||||
|
||||
- **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.
|
||||
|
||||
- **Gitignore Support**: ✅ Fixed - The filesystem tools now honor `.gitignore` patterns and default ignores (`.venv/`, `__pycache__/`, `node_modules/`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI Architecture & Best Practices
|
||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||
|
||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
||||
|
||||
**Correct Structure:**
|
||||
```text
|
||||
to be determined
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (curl for healthcheck)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
EXPOSE 8086
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8086/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8086", "--workers", "1"]
|
||||
@@ -0,0 +1,185 @@
|
||||
# Webber Feature Coverage
|
||||
|
||||
> Tracking progress towards Claude Code-like functionality
|
||||
|
||||
## Current Status: ~40% Complete
|
||||
|
||||
Last updated: 2026-01-10
|
||||
|
||||
---
|
||||
|
||||
## Phase 1-6: Foundation (Original Plan)
|
||||
|
||||
### Phase 1: Tool Infrastructure ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `BaseTool` abstract class | ✅ | `src/domains/tools/base.py` |
|
||||
| `ToolResult` dataclass | ✅ | Consistent success/error/truncated handling |
|
||||
| `ReadFileTool` | ✅ | With line numbers, offset/limit support |
|
||||
| `GlobFilesTool` | ✅ | Pattern matching, sorted by mtime |
|
||||
| `GrepContentTool` | ✅ | Regex search with context lines |
|
||||
| `BashReadOnlyTool` | ✅ | Allowlist-based command filtering |
|
||||
| Path validation | ✅ | `allowed_paths` restriction |
|
||||
|
||||
**Status:** Tools now honor `.gitignore` patterns and default ignores (`.venv/`, `__pycache__/`, etc.)
|
||||
|
||||
### Phase 2: Explore Agent ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `BaseAgent` abstract class | ✅ | `src/domains/agents/base.py` |
|
||||
| Agent registry | ✅ | `register_agent()`, `get_agent()`, `list_agents()` |
|
||||
| `ExploreAgentImpl` | ✅ | PydanticAI-based implementation |
|
||||
| System prompts | ✅ | Mistral-optimized with tool examples |
|
||||
| Tool registration | ✅ | `@agent.tool` decorator pattern |
|
||||
| Sanitized Ollama provider | ✅ | Fixes `content: null` issue |
|
||||
|
||||
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
||||
|
||||
### Phase 3: CLI Foundation ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Typer + Rich setup | ✅ | Both `src/cli` and standalone `cli/` |
|
||||
| `webber --version` | ✅ | Shows version from pyproject.toml |
|
||||
| Console theming | ✅ | Centralized color palette |
|
||||
| Markdown rendering | ✅ | Rich markdown output |
|
||||
|
||||
### Phase 4: Agentic Loop ⚠️ Partial
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `webber chat` command | ✅ | Interactive mode works |
|
||||
| `webber explore` command | ✅ | One-shot query works |
|
||||
| `SessionState` dataclass | ✅ | Basic context tracking |
|
||||
| `AgenticLoop` class | ⚠️ | Basic implementation, not fully utilized |
|
||||
| Conversation history | ❌ | Not persisted between turns in CLI |
|
||||
| Context management | ❌ | No token counting or summarization |
|
||||
|
||||
### Phase 5: REST API ✅ Complete
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `POST /agents/run` | ✅ | Execute agent with prompt |
|
||||
| `GET /agents/` | ✅ | List available agents |
|
||||
| `GET /agents/{name}` | ✅ | Get agent info |
|
||||
| Request/response schemas | ✅ | Pydantic models |
|
||||
|
||||
### Phase 6: Polish & Tests ⚠️ Partial
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Tool unit tests | ✅ | 17 tests covering all tools |
|
||||
| API endpoint tests | ✅ | 5 tests for agent routes |
|
||||
| Health check tests | ✅ | 2 tests |
|
||||
| Integration tests | ❌ | No real LLM integration tests |
|
||||
| CLI E2E tests | ❌ | Not implemented |
|
||||
| Streaming responses | ❌ | Not implemented |
|
||||
|
||||
---
|
||||
|
||||
## Future Work: Remaining Features
|
||||
|
||||
### High Priority
|
||||
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| **Write tool** | Tools | Create new files | Medium |
|
||||
| **Edit tool** | Tools | old_string/new_string pattern like Claude | Medium |
|
||||
| **Full Bash tool** | Tools | Write-enabled shell for Task agent | Medium |
|
||||
| **Plan Agent** | Agents | Design implementation approaches | High |
|
||||
| **Task Agent** | Agents | Autonomous multi-step execution | High |
|
||||
| **Context summarization** | Infrastructure | Compress history at token limit | High |
|
||||
| **Conversation persistence** | CLI | Multi-turn memory in chat mode | Medium |
|
||||
|
||||
### Medium Priority
|
||||
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| **Streaming responses** | CLI | Real-time token display | Medium |
|
||||
| **Web search tool** | Tools | External search API integration | Medium |
|
||||
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
|
||||
| **Session persistence** | CLI | Save/resume conversations | Medium |
|
||||
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
||||
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
||||
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
|
||||
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low |
|
||||
|
||||
### Low Priority
|
||||
|
||||
| Feature | Category | Description | Complexity |
|
||||
|---------|----------|-------------|------------|
|
||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
||||
| **Config file** | CLI | `~/.webber/config.toml` | Low |
|
||||
| **IDE integration** | CLI | VS Code extension | High |
|
||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Testing Coverage Gaps
|
||||
|
||||
| Area | Current | Target | Gap |
|
||||
|------|---------|--------|-----|
|
||||
| Tool unit tests | 17 | 17 | ✅ |
|
||||
| API tests | 5 | 10 | Need error handling, edge cases |
|
||||
| Integration tests | 0 | 5 | Agent + real LLM tests |
|
||||
| CLI E2E tests | 0 | 10 | Full workflow tests |
|
||||
| Security tests | 0 | 5 | Path traversal, injection |
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
||||
|
||||
2. **No conversation memory** - CLI chat mode doesn't persist context between sessions.
|
||||
|
||||
3. **No streaming** - Responses appear all at once, no real-time token display.
|
||||
|
||||
4. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions Made
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Separate CLI package | `cli/` at root | Can be extracted as standalone client |
|
||||
| Sanitized Ollama provider | Custom wrapper | Fixes PydanticAI + Ollama `content: null` bug |
|
||||
| Dev port 8095 | Separate from prod 8086 | Avoid conflicts with Docker deployment |
|
||||
| Tool choice "required" | Force tool use | Mistral Nemo needs explicit instruction |
|
||||
| Temperature 0.3 | Mistral recommendation | 0.0 caused issues with Nemo |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort to Full Parity
|
||||
|
||||
| Milestone | Effort | Features |
|
||||
|-----------|--------|----------|
|
||||
| **MVP (current)** | Done | Explore agent, basic CLI, REST API |
|
||||
| **Usable daily driver** | 2-3 weeks | Write/Edit tools, Plan agent, git integration |
|
||||
| **Claude Code parity** | 2-3 months | Task agent, streaming, MCP, IDE integration |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: What Works Now
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
./wakeup.sh
|
||||
|
||||
# CLI commands
|
||||
.venv/bin/webber-cli status # Check API connection
|
||||
.venv/bin/webber-cli explore "find tests" # One-shot exploration
|
||||
.venv/bin/webber-cli chat # Interactive mode
|
||||
|
||||
# API endpoints
|
||||
curl http://localhost:8095/health
|
||||
curl http://localhost:8095/agents/
|
||||
curl -X POST http://localhost:8095/agents/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}'
|
||||
```
|
||||
@@ -0,0 +1,349 @@
|
||||
# Webber Architecture
|
||||
|
||||
Multi-Agent AI Development System - similar to Claude Code but running locally with configurable models.
|
||||
|
||||
## Overview
|
||||
|
||||
Webber is a FastAPI-based agent orchestration service that provides:
|
||||
- Multi-agent execution (Explore, Plan, Task)
|
||||
- Tool capabilities (file operations, shell, search)
|
||||
- Multi-tenant authentication via Tatlock integration
|
||||
- PydanticAI framework for LLM orchestration
|
||||
|
||||
**Port:** 8086
|
||||
**Runtime:** Python 3.12, FastAPI, Uvicorn
|
||||
**Agent Framework:** PydanticAI
|
||||
**Default LLM:** Ollama with mistral-nemo-large:latest
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
webber/
|
||||
├── src/
|
||||
│ ├── main.py # App entry point (NO routes)
|
||||
│ │
|
||||
│ ├── shared/ # Cross-cutting concerns
|
||||
│ │ ├── base.py # BaseController, BaseSchema
|
||||
│ │ ├── config.py # Pydantic Settings
|
||||
│ │ ├── logging.py # @logged decorator, trace_span
|
||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||
│ │ ├── auth.py # API key validation
|
||||
│ │ └── context.py # UserProvider singleton
|
||||
│ │
|
||||
│ └── domains/ # Feature domains
|
||||
│ ├── router.py # Root router (composes all)
|
||||
│ ├── health/ # Health endpoints
|
||||
│ ├── auth/ # Authentication
|
||||
│ ├── agents/ # Agent orchestration
|
||||
│ │ ├── explore/ # Codebase navigation
|
||||
│ │ ├── plan/ # Implementation design
|
||||
│ │ └── task/ # Execution
|
||||
│ └── tools/ # Tool execution
|
||||
│ ├── file/ # Read, write, glob
|
||||
│ ├── shell/ # Bash execution
|
||||
│ └── search/ # Grep, web search
|
||||
│
|
||||
├── tests/
|
||||
├── docs/
|
||||
└── logs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Patterns
|
||||
|
||||
### 1. Clean main.py
|
||||
|
||||
The entry point contains ONLY:
|
||||
- FastAPI app creation with lifespan
|
||||
- CORS middleware
|
||||
- Auth middleware (sets UserProvider)
|
||||
- Exception handlers
|
||||
- Single router include (`root_router`)
|
||||
|
||||
All routes live in domain routers. This keeps main.py focused on app initialization.
|
||||
|
||||
### 2. Domain-Based Structure
|
||||
|
||||
Each feature domain has its own directory:
|
||||
```
|
||||
domains/
|
||||
├── router.py # Root router composing all domains
|
||||
├── health/
|
||||
│ ├── router.py # Domain routes
|
||||
│ └── controller.py # Business logic
|
||||
├── agents/
|
||||
│ ├── router.py # Agent routes
|
||||
│ ├── controller.py # Orchestration logic
|
||||
│ ├── schemas.py # Request/response models
|
||||
│ └── explore/ # Agent implementation
|
||||
│ ├── agent.py # PydanticAI agent
|
||||
│ └── prompts.py # System prompts
|
||||
```
|
||||
|
||||
### 3. BaseController Pattern
|
||||
|
||||
Controllers use lazy router instantiation:
|
||||
|
||||
```python
|
||||
from src.shared.base import BaseController
|
||||
|
||||
class MyController(BaseController):
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/my", tags=["My"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.get("/")
|
||||
async def list_items():
|
||||
return []
|
||||
|
||||
return router
|
||||
|
||||
my_controller = MyController()
|
||||
# Use: my_controller.router
|
||||
```
|
||||
|
||||
### 4. UserProvider Singleton
|
||||
|
||||
Request-scoped user context without parameter passing:
|
||||
|
||||
```python
|
||||
# In middleware (main.py):
|
||||
user = await validate_api_key(api_key)
|
||||
user_provider.set_user(user)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
|
||||
# Anywhere in code:
|
||||
from src.shared.context import get_current_user, require_user
|
||||
|
||||
user = get_current_user() # Returns None if not authenticated
|
||||
user = require_user() # Raises if not authenticated
|
||||
```
|
||||
|
||||
Uses Python's `contextvars` for async-safe request isolation.
|
||||
|
||||
### 5. Logger with Temporal Benchmarking
|
||||
|
||||
The `@logged()` decorator automatically tracks execution time:
|
||||
|
||||
```python
|
||||
from src.shared.logging import logged, trace_span, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@logged()
|
||||
async def my_function():
|
||||
# Automatically logs entry, exit, and duration
|
||||
pass
|
||||
|
||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||
def critical_path():
|
||||
# Custom thresholds for performance-critical code
|
||||
pass
|
||||
|
||||
async def complex_operation():
|
||||
async with trace_span("llm_call"):
|
||||
# Manual span for specific sections
|
||||
result = await agent.run(prompt)
|
||||
```
|
||||
|
||||
Features:
|
||||
- Trace ID correlation across nested calls
|
||||
- Configurable slow/warn thresholds
|
||||
- DEBUG: all calls logged with timing
|
||||
- INFO: slow calls (>100ms default)
|
||||
- WARNING: very slow calls (>500ms default)
|
||||
- ERROR: failed calls with stack trace
|
||||
|
||||
### 6. Exception Hierarchy
|
||||
|
||||
```python
|
||||
from src.shared.exceptions import (
|
||||
AppException,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
)
|
||||
|
||||
# Raise with context:
|
||||
raise NotFoundError("User", user_id)
|
||||
raise ValidationError("email", "Invalid format")
|
||||
|
||||
# Automatic JSON response via exception handlers in main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings via environment variables or `.env`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| DEBUG | false | Enable debug mode |
|
||||
| LOG_LEVEL | INFO | Logging level |
|
||||
| HOST | 0.0.0.0 | Server host |
|
||||
| PORT | 8086 | Server port |
|
||||
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
||||
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
|
||||
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
||||
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service |
|
||||
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
||||
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||
| SESSION_TTL_HOURS | 24 | Session expiry |
|
||||
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
||||
|
||||
---
|
||||
|
||||
## Agent Architecture
|
||||
|
||||
Webber uses PydanticAI for agent orchestration. Each agent type is purpose-built:
|
||||
|
||||
### Explore Agent
|
||||
Fast codebase exploration for:
|
||||
- Finding files by pattern
|
||||
- Searching code for keywords
|
||||
- Answering questions about structure
|
||||
|
||||
### Plan Agent
|
||||
Implementation design for:
|
||||
- Analyzing requirements
|
||||
- Creating step-by-step plans
|
||||
- Identifying files to modify
|
||||
- Considering trade-offs
|
||||
|
||||
### Task Agent
|
||||
Autonomous execution for:
|
||||
- Multi-step implementations
|
||||
- Tool orchestration
|
||||
- Code generation and modification
|
||||
|
||||
---
|
||||
|
||||
## Tool Architecture
|
||||
|
||||
Tools are sandboxed operations agents can invoke:
|
||||
|
||||
### File Tools
|
||||
- **Read**: Read file contents with line limits
|
||||
- **Write**: Create or overwrite files
|
||||
- **Edit**: String replacement in files
|
||||
- **Glob**: Pattern-based file search
|
||||
|
||||
### Shell Tools
|
||||
- **Bash**: Command execution with timeout
|
||||
- Sandboxed to allowed paths
|
||||
- Captures stdout/stderr
|
||||
|
||||
### Search Tools
|
||||
- **Grep**: Regex content search via ripgrep
|
||||
- **WebSearch**: Web search integration (optional)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client sends `X-API-Key` header
|
||||
2. Auth middleware calls `validate_api_key()`
|
||||
3. Tatlock validates key and returns user info
|
||||
4. UserProvider stores user in request context
|
||||
5. Routes access via `get_current_user()` or `require_user()`
|
||||
6. Middleware clears user in `finally` block
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
ENV PYTHONPATH=/app
|
||||
EXPOSE 8086
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8086/health || exit 1
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8086"]
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
Gitea Actions workflow:
|
||||
1. Push tag `v*` triggers build
|
||||
2. Creates Gitea release
|
||||
3. Builds and pushes Docker image to registry
|
||||
4. Watchtower auto-deploys to production
|
||||
|
||||
### Production Stack
|
||||
|
||||
Deployed in Portainer `agents` stack alongside Tatlock:
|
||||
- Network: `docker-dataplane`
|
||||
- Registry: `git.schweitz.internal/jpmschweitzer/webber`
|
||||
- Auto-update: Watchtower with label `com.centurylinklabs.watchtower.enable=true`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Domains
|
||||
|
||||
1. Create domain directory under `src/domains/`
|
||||
2. Add `router.py` with routes
|
||||
3. Add `controller.py` with business logic
|
||||
4. Add `schemas.py` for request/response models
|
||||
5. Import and include router in `src/domains/router.py`
|
||||
6. Add tests in `tests/test_<domain>.py`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Agents
|
||||
|
||||
1. Create agent directory under `src/domains/agents/`
|
||||
2. Add `agent.py` with PydanticAI agent definition
|
||||
3. Add `prompts.py` with system prompts
|
||||
4. Register in agents controller
|
||||
5. Document in `src/domains/agents/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
1. Create tool file under appropriate `src/domains/tools/` subdir
|
||||
2. Implement tool function with type hints
|
||||
3. Register as PydanticAI tool
|
||||
4. Document in `src/domains/tools/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./wakeup.sh # Start server first
|
||||
pytest tests/ -v
|
||||
|
||||
# With coverage
|
||||
pytest tests/ --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- All tool execution is sandboxed when `SANDBOX_ENABLED=true`
|
||||
- File operations restricted to `ALLOWED_PATHS`
|
||||
- No secrets in prompts
|
||||
- Input validation via Pydantic
|
||||
- Output parsing expects malformed LLM responses
|
||||
- Timeouts on all tool execution
|
||||
@@ -0,0 +1,868 @@
|
||||
# FastAPI Best Practices
|
||||
|
||||
> **A comprehensive guide for building production-grade FastAPI applications.**
|
||||
> Based on patterns from [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) with additional patterns for logging, authentication, and scalable architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Structure
|
||||
|
||||
### Domain-Based Organization (NOT File-Type Based)
|
||||
|
||||
**Do NOT** group files by type (e.g., one huge `routers/` folder). Group by **domain/module** inside a `src/` directory.
|
||||
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # AI/developer guidelines
|
||||
├── README.md # Project overview
|
||||
├── CHANGELOG.md # Version history
|
||||
├── pyproject.toml # Package metadata
|
||||
├── requirements.txt # Production dependencies only
|
||||
├── requirements-dev.txt # Dev/test dependencies
|
||||
├── .env.example # Environment template
|
||||
│
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app, lifespan (NO routes here)
|
||||
│ │
|
||||
│ ├── shared/ # Cross-cutting concerns
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # BaseController, BaseSchema
|
||||
│ │ ├── config.py # Pydantic BaseSettings
|
||||
│ │ ├── logging.py # Logger decorator + centralized setup
|
||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||
│ │ ├── auth.py # Authentication utilities
|
||||
│ │ └── context.py # Request context (user provider, etc.)
|
||||
│ │
|
||||
│ └── domains/ # Feature domains
|
||||
│ ├── __init__.py
|
||||
│ ├── router.py # Root router - composes all domain routers
|
||||
│ │
|
||||
│ ├── health/ # Health check domain
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # Routes
|
||||
│ │ └── controller.py # Business logic
|
||||
│ │
|
||||
│ └── {domain}/ # Each feature domain
|
||||
│ ├── __init__.py
|
||||
│ ├── router.py # Domain routes
|
||||
│ ├── controller.py # Business logic
|
||||
│ ├── schemas.py # Pydantic models
|
||||
│ ├── service.py # External service calls (optional)
|
||||
│ └── exceptions.py # Domain-specific exceptions (optional)
|
||||
│
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── conftest.py # Pytest fixtures
|
||||
│ └── test_{domain}.py
|
||||
│
|
||||
└── docs/
|
||||
└── architecture.md
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Clean main.py**: Only app creation, middleware, lifespan. NO routes.
|
||||
2. **Domain routers**: Each domain has `router.py`. Root `domains/router.py` composes them.
|
||||
3. **Shared utilities**: Cross-cutting concerns in `shared/` - import from there, not across domains.
|
||||
4. **Self-contained domains**: Each domain can be understood in isolation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Base Patterns
|
||||
|
||||
### BaseController
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
class BaseController(ABC):
|
||||
"""
|
||||
Base controller with lazy router instantiation.
|
||||
|
||||
All domain controllers inherit from this and implement create_router().
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str, tags: list[str]):
|
||||
self.prefix = prefix
|
||||
self.tags = tags
|
||||
self._router = None
|
||||
|
||||
@abstractmethod
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the FastAPI router with all routes."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def router(self) -> APIRouter:
|
||||
"""Lazy router instantiation."""
|
||||
if self._router is None:
|
||||
self._router = self.create_router()
|
||||
return self._router
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```python
|
||||
# src/domains/health/controller.py
|
||||
class HealthController(BaseController):
|
||||
def __init__(self):
|
||||
super().__init__(prefix="", tags=["Health"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(tags=self.tags)
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
return router
|
||||
|
||||
health_controller = HealthController()
|
||||
```
|
||||
|
||||
### BaseSchema
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""Base Pydantic model with standardized configuration."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
strict=False,
|
||||
populate_by_name=True,
|
||||
use_enum_values=True,
|
||||
validate_assignment=True,
|
||||
json_encoders={
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
)
|
||||
|
||||
def dict_without_none(self) -> dict[str, Any]:
|
||||
"""Return model as dict, excluding None values."""
|
||||
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration
|
||||
|
||||
### Pydantic Settings
|
||||
|
||||
```python
|
||||
# src/shared/config.py
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Load version from pyproject.toml."""
|
||||
try:
|
||||
with open(Path(__file__).parent.parent.parent / "pyproject.toml", "rb") as f:
|
||||
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
# Application
|
||||
app_name: str = "MyApp"
|
||||
app_version: str = _get_version()
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Logging with Temporal Benchmarking
|
||||
|
||||
### Logger Decorator
|
||||
|
||||
```python
|
||||
# src/shared/logging.py
|
||||
import functools
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
# === Trace Context ===
|
||||
|
||||
@dataclass
|
||||
class TraceSpan:
|
||||
"""Represents a timed execution span."""
|
||||
name: str
|
||||
trace_id: str
|
||||
parent_id: Optional[str] = None
|
||||
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
|
||||
start_time: float = field(default_factory=time.perf_counter)
|
||||
end_time: Optional[float] = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> float:
|
||||
end = self.end_time or time.perf_counter()
|
||||
return (end - self.start_time) * 1000
|
||||
|
||||
|
||||
_current_span: ContextVar[Optional[TraceSpan]] = ContextVar('current_span', default=None)
|
||||
_trace_id: ContextVar[Optional[str]] = ContextVar('trace_id', default=None)
|
||||
|
||||
|
||||
def get_current_trace_id() -> Optional[str]:
|
||||
"""Get current trace ID for log correlation."""
|
||||
return _trace_id.get()
|
||||
|
||||
|
||||
# === Setup ===
|
||||
|
||||
def setup_logging(log_level: str = "INFO") -> None:
|
||||
"""Configure application logging."""
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_dir / "app.log", encoding="utf-8")
|
||||
]
|
||||
)
|
||||
|
||||
# Quiet noisy libraries
|
||||
for name in ["httpx", "httpcore", "uvicorn.access"]:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
# === Decorator ===
|
||||
|
||||
def logged(
|
||||
logger: logging.Logger = None,
|
||||
slow_threshold_ms: float = 100.0,
|
||||
warn_threshold_ms: float = 500.0,
|
||||
include_args: bool = False,
|
||||
):
|
||||
"""
|
||||
Decorator for automatic function logging with temporal benchmarking.
|
||||
|
||||
Args:
|
||||
logger: Logger instance (defaults to module logger)
|
||||
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
|
||||
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
|
||||
include_args: Include function arguments in log (careful with sensitive data)
|
||||
|
||||
Usage:
|
||||
@logged()
|
||||
async def my_function(): ...
|
||||
|
||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||
def critical_path(): ...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
nonlocal logger
|
||||
if logger is None:
|
||||
logger = logging.getLogger(func.__module__)
|
||||
|
||||
func_name = f"{func.__module__}.{func.__qualname__}"
|
||||
|
||||
def _create_span() -> TraceSpan:
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
return TraceSpan(
|
||||
name=func_name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
|
||||
def _log_completion(span: TraceSpan, error: Exception = None):
|
||||
span.end_time = time.perf_counter()
|
||||
duration = span.duration_ms
|
||||
tid = span.trace_id[:8]
|
||||
|
||||
if error:
|
||||
logger.error(f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}", exc_info=True)
|
||||
elif duration >= warn_threshold_ms:
|
||||
logger.warning(f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)")
|
||||
elif duration >= slow_threshold_ms:
|
||||
logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
||||
else:
|
||||
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# === Context Manager ===
|
||||
|
||||
class trace_span:
|
||||
"""
|
||||
Context manager for manual span creation.
|
||||
|
||||
Usage:
|
||||
with trace_span("database_query"):
|
||||
result = db.execute(query)
|
||||
|
||||
async with trace_span("external_api_call"):
|
||||
response = await client.get(url)
|
||||
"""
|
||||
def __init__(self, name: str, logger: logging.Logger = None):
|
||||
self.name = name
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.span: Optional[TraceSpan] = None
|
||||
self.token = None
|
||||
|
||||
def __enter__(self):
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
|
||||
self.span = TraceSpan(
|
||||
name=self.name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
self.token = _current_span.set(self.span)
|
||||
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
|
||||
return self.span
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.span:
|
||||
self.span.end_time = time.perf_counter()
|
||||
duration = self.span.duration_ms
|
||||
tid = self.span.trace_id[:8]
|
||||
if exc_val:
|
||||
self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
|
||||
else:
|
||||
self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
|
||||
if self.token:
|
||||
_current_span.reset(self.token)
|
||||
return False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.__enter__()
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return self.__exit__(exc_type, exc_val, exc_tb)
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
```
|
||||
DEBUG [a1b2c3d4] -> src.domains.users.controller.get_user
|
||||
DEBUG [a1b2c3d4] -> src.domains.users.service.fetch_from_db
|
||||
DEBUG [a1b2c3d4] src.domains.users.service.fetch_from_db: 12.34ms
|
||||
DEBUG [a1b2c3d4] src.domains.users.controller.get_user completed in 15.67ms
|
||||
WARN [a1b2c3d4] src.domains.reports.controller.generate SLOW: 523.45ms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Request Context (User Provider)
|
||||
|
||||
### Singleton Pattern with ContextVars
|
||||
|
||||
```python
|
||||
# src/shared/context.py
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from contextvars import ContextVar
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Authenticated user context."""
|
||||
id: str
|
||||
email: str
|
||||
tenant_id: Optional[str] = None
|
||||
roles: list[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.roles is None:
|
||||
self.roles = []
|
||||
|
||||
|
||||
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
|
||||
|
||||
|
||||
class UserProvider:
|
||||
"""
|
||||
Singleton for request-scoped user context.
|
||||
|
||||
Set once per request in middleware, accessible everywhere without
|
||||
passing user through function parameters.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def set_user(self, user: User) -> None:
|
||||
_current_user.set(user)
|
||||
|
||||
def get_user(self) -> Optional[User]:
|
||||
return _current_user.get()
|
||||
|
||||
def clear_user(self) -> None:
|
||||
_current_user.set(None)
|
||||
|
||||
@property
|
||||
def current_user(self) -> Optional[User]:
|
||||
return self.get_user()
|
||||
|
||||
|
||||
# Global singleton
|
||||
user_provider = UserProvider()
|
||||
|
||||
|
||||
def get_current_user() -> Optional[User]:
|
||||
"""Convenience function to get current user."""
|
||||
return user_provider.get_user()
|
||||
|
||||
|
||||
def require_user() -> User:
|
||||
"""Get current user or raise if not authenticated."""
|
||||
user = user_provider.get_user()
|
||||
if user is None:
|
||||
raise ValueError("No authenticated user in context")
|
||||
return user
|
||||
```
|
||||
|
||||
**Usage in middleware:**
|
||||
```python
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
# Validate token, get user...
|
||||
user = await validate_token(request)
|
||||
if user:
|
||||
user_provider.set_user(user)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
```
|
||||
|
||||
**Usage in any function:**
|
||||
```python
|
||||
from src.shared.context import get_current_user, require_user
|
||||
|
||||
async def some_business_logic():
|
||||
user = require_user() # Raises if not authenticated
|
||||
# Use user.id, user.tenant_id, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Application Entry Point
|
||||
|
||||
### Clean main.py
|
||||
|
||||
```python
|
||||
# src/main.py
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import setup_logging, get_logger
|
||||
from src.domains.router import root_router
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging(settings.log_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application startup and shutdown."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Initialize resources (DB connections, caches, etc.)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup resources
|
||||
logger.info("Shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
docs_url="/docs",
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
debug=settings.debug,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=settings.cors_credentials,
|
||||
allow_methods=settings.cors_methods,
|
||||
allow_headers=settings.cors_headers,
|
||||
)
|
||||
|
||||
# Include all routes from domain router
|
||||
app.include_router(root_router)
|
||||
|
||||
|
||||
# Global exception handler
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error", "type": type(exc).__name__}
|
||||
)
|
||||
```
|
||||
|
||||
### Root Router
|
||||
|
||||
```python
|
||||
# src/domains/router.py
|
||||
from fastapi import APIRouter
|
||||
from src.domains.health.controller import health_controller
|
||||
# from src.domains.users.controller import users_controller
|
||||
# from src.domains.items.controller import items_controller
|
||||
|
||||
root_router = APIRouter()
|
||||
|
||||
# Include all domain routers
|
||||
root_router.include_router(health_controller.router)
|
||||
# root_router.include_router(users_controller.router, prefix="/users", tags=["Users"])
|
||||
# root_router.include_router(items_controller.router, prefix="/items", tags=["Items"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependency Management
|
||||
|
||||
### CRITICAL: Always Start with Latest Safe Versions
|
||||
|
||||
**When setting up a new project, ALWAYS search for the latest version of each package and verify it has no known CVEs.**
|
||||
|
||||
Do NOT copy version numbers from old projects or templates. Package versions in documentation become outdated quickly.
|
||||
|
||||
**Process for each dependency:**
|
||||
1. Search: `"{package} pypi latest version {current_year}"`
|
||||
2. Check PyPI directly: `https://pypi.org/project/{package}/`
|
||||
3. Search: `"{package} CVE vulnerability {current_year}"`
|
||||
4. Verify no unpatched CVEs affect the latest version
|
||||
5. Pin to the verified latest safe version
|
||||
|
||||
**Example search queries:**
|
||||
```
|
||||
"fastapi pypi latest version 2026"
|
||||
"starlette CVE vulnerability 2026"
|
||||
"pydantic-ai pypi latest version 2026"
|
||||
```
|
||||
|
||||
### requirements.txt (Production)
|
||||
|
||||
```
|
||||
# Production Dependencies
|
||||
# Minor version pinning (~=) allows patch updates for security fixes
|
||||
# CVE check date: YYYY-MM-DD <-- UPDATE THIS DATE
|
||||
# CVE sources: PyPI, GitHub Advisories, Snyk, NVD
|
||||
|
||||
# Core - VERIFY LATEST VERSIONS BEFORE USE
|
||||
fastapi~=0.128.0
|
||||
starlette~=0.50.0 # CVE-2025-62727, CVE-2025-54121 fixed
|
||||
uvicorn[standard]~=0.40.0
|
||||
pydantic~=2.12.4 # CVE-2024-3772 (ReDoS) fixed in 2.4.0+
|
||||
pydantic-settings~=2.12.0
|
||||
|
||||
# HTTP client
|
||||
httpx~=0.28.1
|
||||
aiofiles~=25.1.0
|
||||
|
||||
# Utilities
|
||||
python-multipart~=0.0.21
|
||||
python-dotenv~=1.2.1
|
||||
```
|
||||
|
||||
### requirements-dev.txt (Development)
|
||||
|
||||
```
|
||||
# Development Dependencies
|
||||
# NOT included in production Docker image
|
||||
# CVE check date: YYYY-MM-DD <-- UPDATE THIS DATE
|
||||
|
||||
-r requirements.txt
|
||||
|
||||
# Testing - VERIFY LATEST VERSIONS BEFORE USE
|
||||
pytest~=9.0.2
|
||||
anyio~=4.12.1 # Includes pytest-anyio plugin
|
||||
pytest-cov~=7.0.0
|
||||
|
||||
# Security auditing
|
||||
pip-audit~=2.9.0
|
||||
|
||||
# Type checking
|
||||
mypy~=1.19.1
|
||||
|
||||
# Linting (optional)
|
||||
# ruff~=0.9.0
|
||||
```
|
||||
|
||||
### CVE Check Process
|
||||
|
||||
Before adding or updating dependencies:
|
||||
|
||||
1. **Check PyPI** for security advisories: `https://pypi.org/project/{package}/`
|
||||
2. **GitHub Security Advisories**: `https://github.com/advisories`
|
||||
3. **Snyk vulnerability database**: `https://snyk.io/vuln`
|
||||
4. **NVD**: `https://nvd.nist.gov/vuln/search`
|
||||
5. **Run pip-audit**: `pip-audit` before releases
|
||||
|
||||
Document decisions in requirements.txt:
|
||||
```
|
||||
package~=1.2.0 # CVE-YYYY-XXXXX: pinned due to vulnerability in < 1.2.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Exception Handling
|
||||
|
||||
### Custom Exception Hierarchy
|
||||
|
||||
```python
|
||||
# src/shared/exceptions.py
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""Base exception for application errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
def __init__(self, resource: str, identifier: Any):
|
||||
super().__init__(
|
||||
message=f"{resource} not found: {identifier}",
|
||||
status_code=404,
|
||||
details={"resource": resource, "identifier": str(identifier)},
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(AppException):
|
||||
def __init__(self, message: str, field: Optional[str] = None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=422,
|
||||
details={"field": field} if field else {},
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationError(AppException):
|
||||
def __init__(self, message: str = "Authentication required"):
|
||||
super().__init__(message=message, status_code=401)
|
||||
|
||||
|
||||
class AuthorizationError(AppException):
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(message=message, status_code=403)
|
||||
|
||||
|
||||
class ConflictError(AppException):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message=message, status_code=409)
|
||||
|
||||
|
||||
class RateLimitError(AppException):
|
||||
def __init__(self, retry_after: int = 60):
|
||||
super().__init__(
|
||||
message="Rate limit exceeded",
|
||||
status_code=429,
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
```
|
||||
|
||||
### Exception Handler
|
||||
|
||||
```python
|
||||
# In main.py
|
||||
from src.shared.exceptions import AppException
|
||||
|
||||
@app.exception_handler(AppException)
|
||||
async def app_exception_handler(request, exc: AppException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=exc.to_dict(),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
### conftest.py
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Async test client."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
```
|
||||
|
||||
### Example Test
|
||||
|
||||
```python
|
||||
# tests/test_health.py
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_check(client):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary Checklist
|
||||
|
||||
### Project Setup
|
||||
- [ ] Domain-based directory structure (`src/domains/`)
|
||||
- [ ] Shared utilities in `src/shared/`
|
||||
- [ ] Clean `main.py` (no routes, only app setup)
|
||||
- [ ] Root router composing domain routers
|
||||
|
||||
### Patterns
|
||||
- [ ] BaseController with lazy router
|
||||
- [ ] BaseSchema with standard config
|
||||
- [ ] Pydantic Settings for configuration
|
||||
- [ ] Logger decorator with temporal benchmarking
|
||||
- [ ] UserProvider singleton for request context
|
||||
- [ ] Custom exception hierarchy
|
||||
|
||||
### Dependencies
|
||||
- [ ] Minor version pinning (`~=`)
|
||||
- [ ] Separate prod/dev requirements
|
||||
- [ ] CVE check before updates
|
||||
- [ ] pip-audit in CI/CD
|
||||
|
||||
### Quality
|
||||
- [ ] Pytest with async support
|
||||
- [ ] Type hints throughout
|
||||
- [ ] Docstrings on public APIs
|
||||
- [ ] CHANGELOG.md maintained
|
||||
@@ -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
|
||||
@@ -0,0 +1,69 @@
|
||||
[project]
|
||||
name = "webber-api"
|
||||
version = "0.3.0"
|
||||
description = "Webber API - Multi-Agent AI Development Server"
|
||||
authors = [
|
||||
{name = "jpmschweitzer"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Framework :: FastAPI",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Code Generators",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=75.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["src*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
strict = false
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"SIM", # flake8-simplify
|
||||
"TCH", # flake8-type-checking
|
||||
"RUF", # Ruff-specific rules
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # function call in default argument (FastAPI Depends)
|
||||
"B904", # raise without from (sometimes intentional)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["src"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
@@ -0,0 +1,20 @@
|
||||
# Webber Development Dependencies
|
||||
# Install with: pip install -r requirements-dev.txt
|
||||
# NOT included in production Docker image
|
||||
# CVE check date: 2026-01-09
|
||||
|
||||
-r requirements.txt
|
||||
|
||||
# Testing
|
||||
pytest~=9.0.2
|
||||
anyio~=4.12.1 # Includes pytest-anyio plugin
|
||||
pytest-cov~=7.0.0
|
||||
|
||||
# Security auditing - run before releases: pip-audit
|
||||
pip-audit~=2.9.0
|
||||
|
||||
# Type checking
|
||||
mypy~=1.19.1
|
||||
|
||||
# Linting and formatting
|
||||
ruff~=0.9.4
|
||||
@@ -0,0 +1,27 @@
|
||||
# Webber Production Dependencies
|
||||
# Minor version pinning (~=) allows patch updates for security fixes
|
||||
# CVE check date: 2026-01-09
|
||||
# CVE check sources: PyPI, GitHub Advisories, Snyk, NVD
|
||||
|
||||
# Core FastAPI
|
||||
fastapi~=0.128.0
|
||||
starlette~=0.50.0 # CVE-2025-62727, CVE-2025-54121 fixed
|
||||
uvicorn[standard]~=0.40.0
|
||||
pydantic~=2.12.4 # CVE-2024-3772 (ReDoS) fixed in 2.4.0+
|
||||
pydantic-settings~=2.12.0
|
||||
|
||||
# Agent Framework
|
||||
pydantic-ai~=1.40.0
|
||||
|
||||
# HTTP client
|
||||
httpx~=0.28.1
|
||||
aiofiles~=25.1.0
|
||||
|
||||
# CLI
|
||||
typer~=0.15.0
|
||||
rich~=13.9.0
|
||||
|
||||
# Utilities
|
||||
python-multipart~=0.0.21
|
||||
python-dotenv~=1.2.1
|
||||
pathspec~=0.12.1 # Gitignore pattern matching
|
||||
@@ -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,143 @@
|
||||
# Agents Domain
|
||||
|
||||
This domain contains PydanticAI agent definitions and orchestration.
|
||||
|
||||
## Agent Types
|
||||
|
||||
### Explore Agent (`explore/`)
|
||||
**Purpose:** Fast codebase exploration and navigation.
|
||||
|
||||
**Capabilities:**
|
||||
- Find files by glob patterns (e.g., `src/**/*.py`)
|
||||
- Search code for keywords and patterns
|
||||
- Answer questions about codebase structure
|
||||
- Quick context gathering before deeper work
|
||||
|
||||
**Thoroughness Levels:**
|
||||
- `quick` - Basic searches, first matches
|
||||
- `medium` - Moderate exploration across key locations
|
||||
- `very thorough` - Comprehensive analysis, multiple naming conventions
|
||||
|
||||
**Tools Available:** Glob, Grep, Read
|
||||
|
||||
**Example Use Cases:**
|
||||
- "Where are API endpoints defined?"
|
||||
- "Find all files related to authentication"
|
||||
- "What's the project structure?"
|
||||
|
||||
---
|
||||
|
||||
### Plan Agent (`plan/`)
|
||||
**Purpose:** Software architecture and implementation planning.
|
||||
|
||||
**Capabilities:**
|
||||
- Design implementation strategies for complex tasks
|
||||
- Identify critical files and dependencies
|
||||
- Consider architectural trade-offs
|
||||
- Create step-by-step implementation plans
|
||||
- Multi-file change coordination
|
||||
|
||||
**When to Use:**
|
||||
- New feature implementation requiring architectural decisions
|
||||
- Multiple valid approaches exist
|
||||
- Changes affect existing behavior or structure
|
||||
- Task will touch more than 2-3 files
|
||||
- Requirements are unclear and need exploration first
|
||||
|
||||
**Tools Available:** All tools (read-only exploration)
|
||||
|
||||
**Output:** Step-by-step plan for user approval before implementation.
|
||||
|
||||
---
|
||||
|
||||
### Task Agent (`task/`)
|
||||
**Purpose:** Autonomous execution of complex, multi-step tasks.
|
||||
|
||||
**Capabilities:**
|
||||
- Handle tasks requiring multiple tool calls
|
||||
- Work autonomously with full context
|
||||
- Return consolidated results to parent agent
|
||||
- Execute implementation after plan approval
|
||||
|
||||
**Sub-Agent Types (from Task tool):**
|
||||
- `Bash` - Command execution, git operations
|
||||
- `general-purpose` - Research, code search, multi-step tasks
|
||||
- `Explore` - Fast codebase exploration (see above)
|
||||
- `Plan` - Implementation design (see above)
|
||||
|
||||
**Tools Available:** Varies by sub-agent type
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
agents/
|
||||
├── router.py # Agent routes (list, run)
|
||||
├── controller.py # Agent orchestration logic
|
||||
├── schemas.py # Request/response models
|
||||
├── main-system-prompt-reference.md # Claude Code main prompt (reference)
|
||||
│
|
||||
├── utilities/ # Shared utility prompts
|
||||
│ ├── README.md
|
||||
│ ├── todowrite-prompt.md # Task management
|
||||
│ ├── askuserquestion-prompt.md # User clarification
|
||||
│ ├── conversation-summarization-prompt.md
|
||||
│ ├── session-title-prompt.md
|
||||
│ └── security-review-prompt.md
|
||||
│
|
||||
├── explore/
|
||||
│ ├── __init__.py
|
||||
│ ├── agent.py # PydanticAI agent definition
|
||||
│ ├── prompts.py # System prompts
|
||||
│ └── example-prompt.md # Reference from claude-code
|
||||
│
|
||||
├── plan/
|
||||
│ ├── __init__.py
|
||||
│ ├── agent.py
|
||||
│ ├── prompts.py
|
||||
│ └── example-prompt.md # Plan mode + system reminders
|
||||
│
|
||||
└── task/
|
||||
├── __init__.py
|
||||
├── agent.py
|
||||
├── prompts.py
|
||||
└── example-prompt.md # Task agent prompts
|
||||
```
|
||||
|
||||
## PydanticAI Pattern
|
||||
|
||||
```python
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.ollama import OllamaModel
|
||||
from src.shared.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
explore_agent = Agent(
|
||||
OllamaModel(settings.ollama_agent_model, base_url=settings.ollama_url),
|
||||
system_prompt='You are a code exploration assistant...',
|
||||
)
|
||||
|
||||
@explore_agent.tool
|
||||
async def search_files(ctx, pattern: str) -> str:
|
||||
"""Search for files matching pattern."""
|
||||
# Implementation uses tools from src/domains/tools/
|
||||
pass
|
||||
```
|
||||
|
||||
## Adding a New Agent
|
||||
|
||||
1. Create a new directory under `agents/` (e.g., `agents/review/`)
|
||||
2. Create `agent.py` with PydanticAI Agent definition
|
||||
3. Create `prompts.py` with system prompts
|
||||
4. Register in `controller.py`
|
||||
5. Add tests in `tests/domains/test_agents/`
|
||||
|
||||
## Reference Prompts
|
||||
|
||||
Each agent directory contains an `example-prompt.md` file with reference prompts
|
||||
from the claude-code-system-prompts repository. These serve as templates for
|
||||
implementing the PydanticAI agents.
|
||||
|
||||
See also: `main-system-prompt-reference.md` for the core system prompt patterns.
|
||||
@@ -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,45 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: Explore'
|
||||
description: System prompt for the Explore subagent
|
||||
ccVersion: 2.0.56
|
||||
variables:
|
||||
- GLOB_TOOL_NAME
|
||||
- GREP_TOOL_NAME
|
||||
- READ_TOOL_NAME
|
||||
- BASH_TOOL_NAME
|
||||
-->
|
||||
You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
- Use ${GLOB_TOOL_NAME} for broad file pattern matching
|
||||
- Use ${GREP_TOOL_NAME} for searching file contents with regex
|
||||
- Use ${READ_TOOL_NAME} when you know the specific file path you need to read
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
- Communicate your final report directly as a regular message - do NOT attempt to create files
|
||||
|
||||
NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
|
||||
- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
|
||||
- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.
|
||||
@@ -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,147 @@
|
||||
<!--
|
||||
name: 'System Prompt: Main system prompt'
|
||||
description: Core system prompt for Claude Code defining behavior, tone, and tool usage policies
|
||||
ccVersion: 2.0.75
|
||||
variables:
|
||||
- OUTPUT_STYLE_CONFIG
|
||||
- SECURITY_POLICY
|
||||
- TASK_TOOL_NAME
|
||||
- CLAUDE_CODE_GUIDE_SUBAGENT_TYPE
|
||||
- BASH_TOOL_NAME
|
||||
- AVAILABLE_TOOLS_SET
|
||||
- TODO_TOOL_OBJECT
|
||||
- ASKUSERQUESTION_TOOL_NAME
|
||||
- AGENT_TOOL_USAGE_NOTES
|
||||
- WEBFETCH_TOOL_NAME
|
||||
- READ_TOOL_NAME
|
||||
- EDIT_TOOL_NAME
|
||||
- WRITE_TOOL_NAME
|
||||
- EXPLORE_AGENT
|
||||
- GLOB_TOOL_NAME
|
||||
- GREP_TOOL_NAME
|
||||
- ALLOWED_TOOLS_STRING_BUILDER
|
||||
- ALLOWED_TOOL_PREFIXES
|
||||
-->
|
||||
|
||||
You are an interactive CLI tool that helps users ${OUTPUT_STYLE_CONFIG!==null?'according to your "Output Style" below, which describes how you should respond to user queries.':"with software engineering tasks."} Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
${SECURITY_POLICY}
|
||||
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
|
||||
|
||||
If the user asks for help or wants to give feedback inform them of the following:
|
||||
- /help: Get help with using Claude Code
|
||||
- To give feedback, users should ${{ISSUES_EXPLAINER:"report the issue at https://github.com/anthropics/claude-code/issues",PACKAGE_URL:"@anthropic-ai/claude-code",README_URL:"https://code.claude.com/docs/en/overview",VERSION:"<<CCVERSION>>",FEEDBACK_CHANNEL:"https://github.com/anthropics/claude-code/issues",BUILD_TIME:"<<BUILD_TIME>>"}.ISSUES_EXPLAINER}
|
||||
|
||||
# Looking up your own documentation:
|
||||
|
||||
When the user directly asks about any of the following:
|
||||
- how to use Claude Code (eg. "can Claude Code do...", "does Claude Code have...")
|
||||
- what you're able to do as Claude Code in second person (eg. "are you able...", "can you do...")
|
||||
- about how they might do something with Claude Code (eg. "how do I...", "how can I...")
|
||||
- how to use a specific Claude Code feature (eg. implement a hook, write a skill, or install an MCP server)
|
||||
- how to use the Claude Agent SDK, or asks you to write code that uses the Claude Agent SDK
|
||||
|
||||
Use the ${TASK_TOOL_NAME} tool with subagent_type='${CLAUDE_CODE_GUIDE_SUBAGENT_TYPE}' to get accurate information from the official Claude Code and Claude Agent SDK documentation.
|
||||
|
||||
${OUTPUT_STYLE_CONFIG!==null?"":`# Tone and style
|
||||
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
|
||||
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
|
||||
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like ${BASH_TOOL_NAME} or code comments as means to communicate with the user during the session.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
|
||||
|
||||
# Professional objectivity
|
||||
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. Avoid using over-the-top validation or excessive praise when responding to users such as "You're absolutely right" or similar phrases.
|
||||
|
||||
# Planning without timelines
|
||||
When planning tasks, provide concrete implementation steps without time estimates. Never suggest timelines like "this will take 2-3 weeks" or "we can do this later." Focus on what needs to be done, not when. Break work into actionable steps and let users decide scheduling.
|
||||
`}
|
||||
${AVAILABLE_TOOLS_SET.has(TODO_TOOL_OBJECT.name)?`# Task Management
|
||||
You have access to the ${TODO_TOOL_OBJECT.name} tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
|
||||
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
|
||||
|
||||
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
|
||||
|
||||
Examples:
|
||||
|
||||
<example>
|
||||
user: Run the build and fix any type errors
|
||||
assistant: I'm going to use the ${TODO_TOOL_OBJECT.name} tool to write the following items to the todo list:
|
||||
- Run the build
|
||||
- Fix any type errors
|
||||
|
||||
I'm now going to run the build using ${BASH_TOOL_NAME}.
|
||||
|
||||
Looks like I found 10 type errors. I'm going to use the ${TODO_TOOL_OBJECT.name} tool to write 10 items to the todo list.
|
||||
|
||||
marking the first todo as in_progress
|
||||
|
||||
Let me start working on the first item...
|
||||
|
||||
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
|
||||
..
|
||||
..
|
||||
</example>
|
||||
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
|
||||
|
||||
<example>
|
||||
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
|
||||
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the ${TODO_TOOL_OBJECT.name} tool to plan this task.
|
||||
Adding the following todos to the todo list:
|
||||
1. Research existing metrics tracking in the codebase
|
||||
2. Design the metrics collection system
|
||||
3. Implement core metrics tracking functionality
|
||||
4. Create export functionality for different formats
|
||||
|
||||
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
|
||||
|
||||
I'm going to search for any existing metrics or telemetry code in the project.
|
||||
|
||||
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
|
||||
|
||||
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
|
||||
</example>
|
||||
`:""}
|
||||
|
||||
${AVAILABLE_TOOLS_SET.has(ASKUSERQUESTION_TOOL_NAME)?`
|
||||
# Asking questions as you work
|
||||
|
||||
You have access to the ${ASKUSERQUESTION_TOOL_NAME} tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes.
|
||||
`:""}
|
||||
|
||||
Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including <user-prompt-submit-hook>, as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.
|
||||
|
||||
${OUTPUT_STYLE_CONFIG===null||OUTPUT_STYLE_CONFIG.keepCodingInstructions===!0?`# Doing tasks
|
||||
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
|
||||
- NEVER propose changes to code you haven't read. If a user asks about or wants you to modify a file, read it first. Understand existing code before suggesting modifications.
|
||||
- ${AVAILABLE_TOOLS_SET.has(TODO_TOOL_OBJECT.name)?`Use the ${TODO_TOOL_OBJECT.name} tool to plan the task if required`:""}
|
||||
- ${AVAILABLE_TOOLS_SET.has(ASKUSERQUESTION_TOOL_NAME)?`Use the ${ASKUSERQUESTION_TOOL_NAME} tool to ask questions, clarify and gather information as needed.`:""}
|
||||
- Be careful not to introduce security vulnerabilities such as command injection, XSS, SQL injection, and other OWASP top 10 vulnerabilities. If you notice that you wrote insecure code, immediately fix it.
|
||||
- Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused.
|
||||
- Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability. Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.
|
||||
- Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). Don't use feature flags or backwards-compatibility shims when you can just change the code.
|
||||
- Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task—three similar lines of code is better than a premature abstraction.
|
||||
- Avoid backwards-compatibility hacks like renaming unused \`_vars\`, re-exporting types, adding \`// removed\` comments for removed code, etc. If something is unused, delete it completely.
|
||||
`:""}
|
||||
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
|
||||
- The conversation has unlimited context through automatic summarization.
|
||||
|
||||
|
||||
# Tool usage policy${AVAILABLE_TOOLS_SET.has(TASK_TOOL_NAME)?`
|
||||
- When doing file search, prefer to use the ${TASK_TOOL_NAME} tool in order to reduce context usage.
|
||||
- You should proactively use the ${TASK_TOOL_NAME} tool with specialized agents when the task at hand matches the agent's description.
|
||||
${AGENT_TOOL_USAGE_NOTES}`:""}${AVAILABLE_TOOLS_SET.has(WEBFETCH_TOOL_NAME)?`
|
||||
- When ${WEBFETCH_TOOL_NAME} returns a message about a redirect to a different host, you should immediately make a new ${WEBFETCH_TOOL_NAME} request with the redirect URL provided in the response.`:""}
|
||||
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
|
||||
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple ${TASK_TOOL_NAME} tool calls.
|
||||
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: ${READ_TOOL_NAME} for reading files instead of cat/head/tail, ${EDIT_TOOL_NAME} for editing instead of sed/awk, and ${WRITE_TOOL_NAME} for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
|
||||
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType} instead of running search commands directly.
|
||||
<example>
|
||||
user: Where are errors from the client handled?
|
||||
assistant: [Uses the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType} to find the files that handle client errors instead of using ${GLOB_TOOL_NAME} or ${GREP_TOOL_NAME} directly]
|
||||
</example>
|
||||
<example>
|
||||
user: What is the codebase structure?
|
||||
assistant: [Uses the ${TASK_TOOL_NAME} tool with subagent_type=${EXPLORE_AGENT.agentType}]
|
||||
</example>
|
||||
|
||||
${ALLOWED_TOOLS_STRING_BUILDER(ALLOWED_TOOL_PREFIXES)}
|
||||
@@ -0,0 +1,147 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: Plan mode (enhanced)'
|
||||
description: Enhanced prompt for the Plan subagent
|
||||
ccVersion: 2.0.56
|
||||
variables:
|
||||
- GLOB_TOOL_NAME
|
||||
- GREP_TOOL_NAME
|
||||
- READ_TOOL_NAME
|
||||
- BASH_TOOL_NAME
|
||||
-->
|
||||
You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
|
||||
|
||||
## Your Process
|
||||
|
||||
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
|
||||
|
||||
2. **Explore Thoroughly**:
|
||||
- Read any files provided to you in the initial prompt
|
||||
- Find existing patterns and conventions using ${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${READ_TOOL_NAME}
|
||||
- Understand the current architecture
|
||||
- Identify similar features as reference
|
||||
- Trace through relevant code paths
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
|
||||
3. **Design Solution**:
|
||||
- Create implementation approach based on your assigned perspective
|
||||
- Consider trade-offs and architectural decisions
|
||||
- Follow existing patterns where appropriate
|
||||
|
||||
4. **Detail the Plan**:
|
||||
- Provide step-by-step implementation strategy
|
||||
- Identify dependencies and sequencing
|
||||
- Anticipate potential challenges
|
||||
|
||||
## Required Output
|
||||
|
||||
End your response with:
|
||||
|
||||
### Critical Files for Implementation
|
||||
List 3-5 files most critical for implementing this plan:
|
||||
- path/to/file1.ts - [Brief reason: e.g., "Core logic to modify"]
|
||||
- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]
|
||||
- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]
|
||||
|
||||
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Plan Mode System Reminders
|
||||
|
||||
<!--
|
||||
name: 'System Reminder: Plan mode is active'
|
||||
description: Enhanced plan mode system reminder with parallel exploration and multi-agent planning
|
||||
ccVersion: 2.0.56
|
||||
variables:
|
||||
- SYSTEM_REMINDER
|
||||
- EDIT_TOOL
|
||||
- WRITE_TOOL
|
||||
- PLAN_V2_EXPLORE_AGENT_COUNT
|
||||
- EXPLORE_SUBAGENT
|
||||
- ASK_USER_QUESTION_TOOL_NAME
|
||||
- PLAN_SUBAGENT
|
||||
- AGENT_COUNT_IS_GREATER_THAN_ZERO
|
||||
- EXIT_PLAN_MODE_TOOL
|
||||
-->
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
|
||||
|
||||
## Plan File Info:
|
||||
${SYSTEM_REMINDER.planExists?`A plan file already exists at ${SYSTEM_REMINDER.planFilePath}. You can read it and make incremental edits using the ${EDIT_TOOL.name} tool.`:`No plan file exists yet. You should create your plan at ${SYSTEM_REMINDER.planFilePath} using the ${WRITE_TOOL.name} tool.`}
|
||||
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
|
||||
|
||||
## Plan Workflow
|
||||
|
||||
### Phase 1: Initial Understanding
|
||||
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the ${PLAN_V2_EXPLORE_AGENT_COUNT.agentType} subagent type.
|
||||
|
||||
1. Focus on understanding the user's request and the code associated with their request
|
||||
|
||||
2. **Launch up to ${EXPLORE_SUBAGENT} ${PLAN_V2_EXPLORE_AGENT_COUNT.agentType} agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
|
||||
- Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
|
||||
- Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
|
||||
- Quality over quantity - ${EXPLORE_SUBAGENT} agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
|
||||
- If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
|
||||
|
||||
3. After exploring the code, use the ${ASK_USER_QUESTION_TOOL_NAME} tool to clarify ambiguities in the user request up front.
|
||||
|
||||
### Phase 2: Design
|
||||
Goal: Design an implementation approach.
|
||||
|
||||
Launch ${PLAN_SUBAGENT.agentType} agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
|
||||
|
||||
You can launch up to ${AGENT_COUNT_IS_GREATER_THAN_ZERO} agent(s) in parallel.
|
||||
|
||||
**Guidelines:**
|
||||
- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
|
||||
- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
|
||||
${AGENT_COUNT_IS_GREATER_THAN_ZERO>1?`- **Multiple agents**: Use up to ${AGENT_COUNT_IS_GREATER_THAN_ZERO} agents for complex tasks that benefit from different perspectives
|
||||
|
||||
Examples of when to use multiple agents:
|
||||
- The task touches multiple parts of the codebase
|
||||
- It's a large refactor or architectural change
|
||||
- There are many edge cases to consider
|
||||
- You'd benefit from exploring different approaches
|
||||
|
||||
Example perspectives by task type:
|
||||
- New feature: simplicity vs performance vs maintainability
|
||||
- Bug fix: root cause vs workaround vs prevention
|
||||
- Refactoring: minimal change vs clean architecture
|
||||
`:""}
|
||||
In the agent prompt:
|
||||
- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
|
||||
- Describe requirements and constraints
|
||||
- Request a detailed implementation plan
|
||||
|
||||
### Phase 3: Review
|
||||
Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
|
||||
1. Read the critical files identified by agents to deepen your understanding
|
||||
2. Ensure that the plans align with the user's original request
|
||||
3. Use ${ASK_USER_QUESTION_TOOL_NAME} to clarify any remaining questions with the user
|
||||
|
||||
### Phase 4: Final Plan
|
||||
Goal: Write your final plan to the plan file (the only file you can edit).
|
||||
- Include only your recommended approach, not all alternatives
|
||||
- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
|
||||
- Include the paths of critical files to be modified
|
||||
|
||||
### Phase 5: Call ${EXIT_PLAN_MODE_TOOL.name}
|
||||
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call ${EXIT_PLAN_MODE_TOOL.name} to indicate to the user that you are done planning.
|
||||
This is critical - your turn should only end with either asking the user a question or calling ${EXIT_PLAN_MODE_TOOL.name}. Do not stop unless it's for these 2 reasons.
|
||||
|
||||
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
||||
@@ -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]
|
||||
@@ -0,0 +1,105 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: Task tool'
|
||||
description: System prompt given to the subagent spawned via the Task tool
|
||||
ccVersion: 2.0.14
|
||||
-->
|
||||
You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.
|
||||
|
||||
Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
|
||||
Guidelines:
|
||||
- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
|
||||
- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.
|
||||
- For clear communication, avoid using emojis.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Task Tool Description
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Task'
|
||||
description: Tool description for launching specialized sub-agents to handle complex tasks
|
||||
ccVersion: 2.0.72
|
||||
variables:
|
||||
- TASK_TOOL
|
||||
- AGENT_TYPE_REGISTRY_STRING
|
||||
- READ_TOOL
|
||||
- GLOB_TOOL
|
||||
- TASK_TOOL
|
||||
- WRITE_TOOL
|
||||
- AGENT_OUTPUT_TOOL
|
||||
-->
|
||||
Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
The ${TASK_TOOL} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
||||
|
||||
Available agent types and the tools they have access to:
|
||||
${AGENT_TYPE_REGISTRY_STRING}
|
||||
|
||||
When using the ${TASK_TOOL} tool, you must specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
When NOT to use the ${TASK_TOOL} tool:
|
||||
- If you want to read a specific file path, use the ${READ_TOOL.name} or ${GLOB_TOOL.name} tool instead of the ${TASK_TOOL} tool, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use the ${GLOB_TOOL.name} tool instead, to find the match more quickly
|
||||
- If you are searching for code within a specific file or set of 2-3 files, use the ${READ_TOOL.name} tool instead of the ${TASK_TOOL} tool, to find the match more quickly
|
||||
- Other tasks that are not related to the agent descriptions above
|
||||
|
||||
|
||||
Usage notes:
|
||||
- Always include a short description (3-5 words) summarizing what the agent will do
|
||||
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
|
||||
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
|
||||
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will need to use ${TASK_TOOL} to retrieve its results once it's done. You can continue to work while background agents run - When you need their results to continue you can use ${TASK_TOOL} in blocking mode to pause and wait for their results.
|
||||
- Agents can be resumed using the \`resume\` parameter by passing the agent ID from a previous invocation. When resumed, the agent continues with its full previous context preserved. When NOT resuming, each invocation starts fresh and you should provide a detailed task description with all necessary context.
|
||||
- When the agent is done, it will return a single message back to you along with its agent ID. You can use this ID to resume the agent later if needed for follow-up work.
|
||||
- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
|
||||
- Agents with "access to current context" can see the full conversation history before the tool call. When using these agents, you can write concise prompts that reference earlier context (e.g., "investigate the error discussed above") instead of repeating information. The agent will receive all prior messages and understand the context.
|
||||
- The agent's outputs should generally be trusted
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${WRITE_TOOL.name} tool use content blocks. For example, if you need to launch both a code-reviewer agent and a test-runner agent in parallel, send a single message with both tool calls.
|
||||
|
||||
Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"code-reviewer": use this agent after you are done writing a signficant piece of code
|
||||
"greeting-responder": use this agent when to respond to user greetings with a friendly joke
|
||||
</example_agent_description>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: Sure let me write a function that checks if a number is prime
|
||||
assistant: First let me use the ${AGENT_OUTPUT_TOOL.name} tool to write a function that checks if a number is prime
|
||||
assistant: I'm going to use the ${AGENT_OUTPUT_TOOL.name} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code
|
||||
</commentary>
|
||||
assistant: Now let me use the code-reviewer agent to review the code
|
||||
assistant: Uses the ${WRITE_TOOL.name} tool to launch the code-reviewer agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${WRITE_TOOL.name} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Utilities
|
||||
|
||||
Reference prompts for utility functions used across agents.
|
||||
|
||||
## Prompts
|
||||
|
||||
### todowrite-prompt.md
|
||||
**Purpose:** Task list management for tracking progress.
|
||||
|
||||
Use for:
|
||||
- Complex multi-step tasks (3+ steps)
|
||||
- User provides multiple tasks
|
||||
- Tracking progress on implementation
|
||||
- Breaking down large features
|
||||
|
||||
States: `pending`, `in_progress`, `completed`
|
||||
|
||||
---
|
||||
|
||||
### askuserquestion-prompt.md
|
||||
**Purpose:** Interactive clarification during execution.
|
||||
|
||||
Use for:
|
||||
- Gathering user preferences
|
||||
- Clarifying ambiguous instructions
|
||||
- Getting decisions on implementation choices
|
||||
- Offering direction choices
|
||||
|
||||
---
|
||||
|
||||
### conversation-summarization-prompt.md
|
||||
**Purpose:** Compacting long conversations for context management.
|
||||
|
||||
Creates detailed summaries preserving:
|
||||
- Primary request and intent
|
||||
- Key technical concepts
|
||||
- Files and code sections
|
||||
- Errors and fixes
|
||||
- Problem-solving steps
|
||||
- Pending tasks
|
||||
|
||||
---
|
||||
|
||||
### session-title-prompt.md
|
||||
**Purpose:** Generate concise session titles and git branch names.
|
||||
|
||||
Output format:
|
||||
- Title: 3-6 words, no quotes
|
||||
- Branch: kebab-case, 2-4 words (e.g., `add-user-auth`)
|
||||
|
||||
---
|
||||
|
||||
### security-review-prompt.md
|
||||
**Purpose:** Comprehensive security analysis of code changes.
|
||||
|
||||
Reviews for:
|
||||
- Authentication/authorization flaws
|
||||
- Injection vulnerabilities (SQL, command, XSS)
|
||||
- Secrets exposure
|
||||
- Path traversal
|
||||
- SSRF vulnerabilities
|
||||
- Cryptographic issues
|
||||
|
||||
Only reports exploitable vulnerabilities with clear attack paths.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
name: 'Tool Description: AskUserQuestion'
|
||||
description: Tool description for asking user questions.
|
||||
ccVersion: 2.0.62
|
||||
-->
|
||||
Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- Users will always be able to select "Other" to provide custom text input
|
||||
- Use multiSelect: true to allow multiple answers to be selected for a question
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
||||
@@ -0,0 +1,100 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: Conversation summarization'
|
||||
description: System prompt for creating detailed conversation summaries
|
||||
ccVersion: 2.0.14
|
||||
-->
|
||||
Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
|
||||
This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.
|
||||
|
||||
Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process:
|
||||
|
||||
1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:
|
||||
- The user's explicit requests and intents
|
||||
- Your approach to addressing the user's requests
|
||||
- Key decisions, technical concepts and code patterns
|
||||
- Specific details like:
|
||||
- file names
|
||||
- full code snippets
|
||||
- function signatures
|
||||
- file edits
|
||||
- Errors that you ran into and how you fixed them
|
||||
- Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
|
||||
2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.
|
||||
|
||||
Your summary should include the following sections:
|
||||
|
||||
1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail
|
||||
2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed.
|
||||
3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.
|
||||
4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
|
||||
5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.
|
||||
6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent.
|
||||
6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.
|
||||
7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.
|
||||
8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.
|
||||
If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.
|
||||
|
||||
Here's an example of how your output should be structured:
|
||||
|
||||
<example>
|
||||
<analysis>
|
||||
[Your thought process, ensuring all points are covered thoroughly and accurately]
|
||||
</analysis>
|
||||
|
||||
<summary>
|
||||
1. Primary Request and Intent:
|
||||
[Detailed description]
|
||||
|
||||
2. Key Technical Concepts:
|
||||
- [Concept 1]
|
||||
- [Concept 2]
|
||||
- [...]
|
||||
|
||||
3. Files and Code Sections:
|
||||
- [File Name 1]
|
||||
- [Summary of why this file is important]
|
||||
- [Summary of the changes made to this file, if any]
|
||||
- [Important Code Snippet]
|
||||
- [File Name 2]
|
||||
- [Important Code Snippet]
|
||||
- [...]
|
||||
|
||||
4. Errors and fixes:
|
||||
- [Detailed description of error 1]:
|
||||
- [How you fixed the error]
|
||||
- [User feedback on the error if any]
|
||||
- [...]
|
||||
|
||||
5. Problem Solving:
|
||||
[Description of solved problems and ongoing troubleshooting]
|
||||
|
||||
6. All user messages:
|
||||
- [Detailed non tool use user message]
|
||||
- [...]
|
||||
|
||||
7. Pending Tasks:
|
||||
- [Task 1]
|
||||
- [Task 2]
|
||||
- [...]
|
||||
|
||||
8. Current Work:
|
||||
[Precise description of current work]
|
||||
|
||||
9. Optional Next Step:
|
||||
[Optional Next step to take]
|
||||
|
||||
</summary>
|
||||
</example>
|
||||
|
||||
Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response.
|
||||
|
||||
There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include:
|
||||
<example>
|
||||
## Compact Instructions
|
||||
When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
# Summary instructions
|
||||
When you are using compact - please focus on test output and code changes. Include file reads verbatim.
|
||||
</example>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: /security-review slash'
|
||||
description: Comprehensive security review prompt for analyzing code changes with focus on exploitable vulnerabilities
|
||||
ccVersion: 2.0.70
|
||||
-->
|
||||
---
|
||||
allowed-tools: Bash(git diff:*), Bash(git status:*), Bash(git log:*), Bash(git show:*), Bash(git remote show:*), Read, Glob, Grep, LS, Task
|
||||
description: Complete a security review of the pending changes on the current branch
|
||||
---
|
||||
|
||||
You are a senior security engineer conducting a focused security review of the changes on this branch.
|
||||
|
||||
GIT STATUS:
|
||||
|
||||
\`\`\`
|
||||
!\`git status\`
|
||||
\`\`\`
|
||||
|
||||
FILES MODIFIED:
|
||||
|
||||
\`\`\`
|
||||
!\`git diff --name-only origin/HEAD...\`
|
||||
\`\`\`
|
||||
|
||||
COMMITS:
|
||||
|
||||
\`\`\`
|
||||
!\`git log --no-decorate origin/HEAD...\`
|
||||
\`\`\`
|
||||
|
||||
DIFF CONTENT:
|
||||
|
||||
\`\`\`
|
||||
!\`git diff --merge-base origin/HEAD\`
|
||||
\`\`\`
|
||||
|
||||
Review the complete diff above. This contains all code changes in the PR.
|
||||
|
||||
|
||||
OBJECTIVE:
|
||||
Perform a security-focused code review to identify HIGH-CONFIDENCE security vulnerabilities that could have real exploitation potential. This is not a general code review - focus ONLY on security implications newly added by this PR. Do not comment on existing security concerns.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. MINIMIZE FALSE POSITIVES: Only flag issues where you're >80% confident of actual exploitability
|
||||
2. AVOID NOISE: Skip theoretical issues, style concerns, or low-impact findings
|
||||
3. FOCUS ON IMPACT: Prioritize vulnerabilities that could lead to unauthorized access, data breaches, or system compromise
|
||||
4. EXCLUSIONS: Do NOT report the following issue types:
|
||||
- Denial of Service (DOS) vulnerabilities, even if they allow service disruption
|
||||
- Secrets or sensitive data stored on disk (these are handled by other processes)
|
||||
- Rate limiting or resource exhaustion issues
|
||||
|
||||
SECURITY CATEGORIES TO EXAMINE:
|
||||
|
||||
**Input Validation Vulnerabilities:**
|
||||
- SQL injection via unsanitized user input
|
||||
- Command injection in system calls or subprocesses
|
||||
- XXE injection in XML parsing
|
||||
- Template injection in templating engines
|
||||
- NoSQL injection in database queries
|
||||
- Path traversal in file operations
|
||||
|
||||
**Authentication & Authorization Issues:**
|
||||
- Authentication bypass logic
|
||||
- Privilege escalation paths
|
||||
- Session management flaws
|
||||
- JWT token vulnerabilities
|
||||
- Authorization logic bypasses
|
||||
|
||||
**Crypto & Secrets Management:**
|
||||
- Hardcoded API keys, passwords, or tokens
|
||||
- Weak cryptographic algorithms or implementations
|
||||
- Improper key storage or management
|
||||
- Cryptographic randomness issues
|
||||
- Certificate validation bypasses
|
||||
|
||||
**Injection & Code Execution:**
|
||||
- Remote code execution via deseralization
|
||||
- Pickle injection in Python
|
||||
- YAML deserialization vulnerabilities
|
||||
- Eval injection in dynamic code execution
|
||||
- XSS vulnerabilities in web applications (reflected, stored, DOM-based)
|
||||
|
||||
**Data Exposure:**
|
||||
- Sensitive data logging or storage
|
||||
- PII handling violations
|
||||
- API endpoint data leakage
|
||||
- Debug information exposure
|
||||
|
||||
Additional notes:
|
||||
- Even if something is only exploitable from the local network, it can still be a HIGH severity issue
|
||||
|
||||
ANALYSIS METHODOLOGY:
|
||||
|
||||
Phase 1 - Repository Context Research (Use file search tools):
|
||||
- Identify existing security frameworks and libraries in use
|
||||
- Look for established secure coding patterns in the codebase
|
||||
- Examine existing sanitization and validation patterns
|
||||
- Understand the project's security model and threat model
|
||||
|
||||
Phase 2 - Comparative Analysis:
|
||||
- Compare new code changes against existing security patterns
|
||||
- Identify deviations from established secure practices
|
||||
- Look for inconsistent security implementations
|
||||
- Flag code that introduces new attack surfaces
|
||||
|
||||
Phase 3 - Vulnerability Assessment:
|
||||
- Examine each modified file for security implications
|
||||
- Trace data flow from user inputs to sensitive operations
|
||||
- Look for privilege boundaries being crossed unsafely
|
||||
- Identify injection points and unsafe deserialization
|
||||
|
||||
REQUIRED OUTPUT FORMAT:
|
||||
|
||||
You MUST output your findings in markdown. The markdown output should contain the file, line number, severity, category (e.g. \`sql_injection\` or \`xss\`), description, exploit scenario, and fix recommendation.
|
||||
|
||||
For example:
|
||||
|
||||
# Vuln 1: XSS: \`foo.py:42\`
|
||||
|
||||
* Severity: High
|
||||
* Description: User input from \`username\` parameter is directly interpolated into HTML without escaping, allowing reflected XSS attacks
|
||||
* Exploit Scenario: Attacker crafts URL like /bar?q=<script>alert(document.cookie)</script> to execute JavaScript in victim's browser, enabling session hijacking or data theft
|
||||
* Recommendation: Use Flask's escape() function or Jinja2 templates with auto-escaping enabled for all user inputs rendered in HTML
|
||||
|
||||
SEVERITY GUIDELINES:
|
||||
- **HIGH**: Directly exploitable vulnerabilities leading to RCE, data breach, or authentication bypass
|
||||
- **MEDIUM**: Vulnerabilities requiring specific conditions but with significant impact
|
||||
- **LOW**: Defense-in-depth issues or lower-impact vulnerabilities
|
||||
|
||||
CONFIDENCE SCORING:
|
||||
- 0.9-1.0: Certain exploit path identified, tested if possible
|
||||
- 0.8-0.9: Clear vulnerability pattern with known exploitation methods
|
||||
- 0.7-0.8: Suspicious pattern requiring specific conditions to exploit
|
||||
- Below 0.7: Don't report (too speculative)
|
||||
|
||||
FINAL REMINDER:
|
||||
Focus on HIGH and MEDIUM findings only. Better to miss some theoretical issues than flood the report with false positives. Each finding should be something a security engineer would confidently raise in a PR review.
|
||||
|
||||
FALSE POSITIVE FILTERING:
|
||||
|
||||
> You do not need to run commands to reproduce the vulnerability, just read the code to determine if it is a real vulnerability. Do not use the bash tool or write to any files.
|
||||
>
|
||||
> HARD EXCLUSIONS - Automatically exclude findings matching these patterns:
|
||||
> 1. Denial of Service (DOS) vulnerabilities or resource exhaustion attacks.
|
||||
> 2. Secrets or credentials stored on disk if they are otherwise secured.
|
||||
> 3. Rate limiting concerns or service overload scenarios.
|
||||
> 4. Memory consumption or CPU exhaustion issues.
|
||||
> 5. Lack of input validation on non-security-critical fields without proven security impact.
|
||||
> 6. Input sanitization concerns for GitHub Action workflows unless they are clearly triggerable via untrusted input.
|
||||
> 7. A lack of hardening measures. Code is not expected to implement all security best practices, only flag concrete vulnerabilities.
|
||||
> 8. Race conditions or timing attacks that are theoretical rather than practical issues. Only report a race condition if it is concretely problematic.
|
||||
> 9. Vulnerabilities related to outdated third-party libraries. These are managed separately and should not be reported here.
|
||||
> 10. Memory safety issues such as buffer overflows or use-after-free-vulnerabilities are impossible in rust. Do not report memory safety issues in rust or any other memory safe languages.
|
||||
> 11. Files that are only unit tests or only used as part of running tests.
|
||||
> 12. Log spoofing concerns. Outputting un-sanitized user input to logs is not a vulnerability.
|
||||
> 13. SSRF vulnerabilities that only control the path. SSRF is only a concern if it can control the host or protocol.
|
||||
> 14. Including user-controlled content in AI system prompts is not a vulnerability.
|
||||
> 15. Regex injection. Injecting untrusted content into a regex is not a vulnerability.
|
||||
> 16. Regex DOS concerns.
|
||||
> 16. Insecure documentation. Do not report any findings in documentation files such as markdown files.
|
||||
> 17. A lack of audit logs is not a vulnerability.
|
||||
>
|
||||
> PRECEDENTS -
|
||||
> 1. Logging high value secrets in plaintext is a vulnerability. Logging URLs is assumed to be safe.
|
||||
> 2. UUIDs can be assumed to be unguessable and do not need to be validated.
|
||||
> 3. Environment variables and CLI flags are trusted values. Attackers are generally not able to modify them in a secure environment. Any attack that relies on controlling an environment variable is invalid.
|
||||
> 4. Resource management issues such as memory or file descriptor leaks are not valid.
|
||||
> 5. Subtle or low impact web vulnerabilities such as tabnabbing, XS-Leaks, prototype pollution, and open redirects should not be reported unless they are extremely high confidence.
|
||||
> 6. React and Angular are generally secure against XSS. These frameworks do not need to sanitize or escape user input unless it is using dangerouslySetInnerHTML, bypassSecurityTrustHtml, or similar methods. Do not report XSS vulnerabilities in React or Angular components or tsx files unless they are using unsafe methods.
|
||||
> 7. Most vulnerabilities in github action workflows are not exploitable in practice. Before validating a github action workflow vulnerability ensure it is concrete and has a very specific attack path.
|
||||
> 8. A lack of permission checking or authentication in client-side JS/TS code is not a vulnerability. Client-side code is not trusted and does not need to implement these checks, they are handled on the server-side. The same applies to all flows that send untrusted data to the backend, the backend is responsible for validating and sanitizing all inputs.
|
||||
> 9. Only include MEDIUM findings if they are obvious and concrete issues.
|
||||
> 10. Most vulnerabilities in ipython notebooks (*.ipynb files) are not exploitable in practice. Before validating a notebook vulnerability ensure it is concrete and has a very specific attack path where untrusted input can trigger the vulnerability.
|
||||
> 11. Logging non-PII data is not a vulnerability even if the data may be sensitive. Only report logging vulnerabilities if they expose sensitive information such as secrets, passwords, or personally identifiable information (PII).
|
||||
> 12. Command injection vulnerabilities in shell scripts are generally not exploitable in practice since shell scripts generally do not run with untrusted user input. Only report command injection vulnerabilities in shell scripts if they are concrete and have a very specific attack path for untrusted input.
|
||||
>
|
||||
> SIGNAL QUALITY CRITERIA - For remaining findings, assess:
|
||||
> 1. Is there a concrete, exploitable vulnerability with a clear attack path?
|
||||
> 2. Does this represent a real security risk vs theoretical best practice?
|
||||
> 3. Are there specific code locations and reproduction steps?
|
||||
> 4. Would this finding be actionable for a security team?
|
||||
>
|
||||
> For each finding, assign a confidence score from 1-10:
|
||||
> - 1-3: Low confidence, likely false positive or noise
|
||||
> - 4-6: Medium confidence, needs investigation
|
||||
> - 7-10: High confidence, likely true vulnerability
|
||||
|
||||
START ANALYSIS:
|
||||
|
||||
Begin your analysis now. Do this in 3 steps:
|
||||
|
||||
1. Use a sub-task to identify vulnerabilities. Use the repository exploration tools to understand the codebase context, then analyze the PR changes for security implications. In the prompt for this sub-task, include all of the above.
|
||||
2. Then for each vulnerability identified by the above sub-task, create a new sub-task to filter out false-positives. Launch these sub-tasks as parallel sub-tasks. In the prompt for these sub-tasks, include everything in the "FALSE POSITIVE FILTERING" instructions.
|
||||
3. Filter out any vulnerabilities where the sub-task reported a confidence less than 8.
|
||||
|
||||
Your final reply must contain the markdown report and nothing else.
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
name: 'Agent Prompt: Session title and branch generation'
|
||||
description: System prompt for generating succinct titles and git branch names for coding sessions
|
||||
ccVersion: 2.0.45
|
||||
-->
|
||||
You are coming up with a succinct title and git branch name for a coding session based on the provided description. The title should be clear, concise, and accurately reflect the content of the coding task.
|
||||
You should keep it short and simple, ideally no more than 6 words. Avoid using jargon or overly technical terms unless absolutely necessary. The title should be easy to understand for anyone reading it.
|
||||
You should wrap the title in <title> tags.
|
||||
|
||||
The branch name should be clear, concise, and accurately reflect the content of the coding task.
|
||||
You should keep it short and simple, ideally no more than 4 words. The branch should always start with "claude/" and should be all lower case, with words separated by dashes.
|
||||
You should wrap the branch name in <branch> tags.
|
||||
|
||||
The title should always come first, followed by the branch. Do not include any other text other than the title and branch.
|
||||
|
||||
Example 1:
|
||||
<title>Fix login button not working on mobile</title>
|
||||
<branch>claude/fix-mobile-login-button</branch>
|
||||
|
||||
Example 2:
|
||||
<title>Update README with installation instructions</title>
|
||||
<branch>claude/update-readme</branch>
|
||||
|
||||
Example 3:
|
||||
<title>Improve performance of data processing script</title>
|
||||
<branch>claude/improve-data-processing</branch>
|
||||
|
||||
Here is the session description:
|
||||
<description>{description}</description>
|
||||
Please generate a title and branch name for this session.
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
name: 'Tool Description: TodoWrite'
|
||||
description: Tool description for creating and managing task lists
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- EDIT_TOOL_NAME
|
||||
-->
|
||||
Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
|
||||
It also helps the user understand the progress of the task and overall progress of their requests.
|
||||
|
||||
## When to Use This Tool
|
||||
Use this tool proactively in these scenarios:
|
||||
|
||||
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
|
||||
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
|
||||
3. User explicitly requests todo list - When the user directly asks you to use the todo list
|
||||
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
|
||||
5. After receiving new instructions - Immediately capture user requirements as todos
|
||||
6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time
|
||||
7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip using this tool when:
|
||||
1. There is only a single, straightforward task
|
||||
2. The task is trivial and tracking it provides no organizational benefit
|
||||
3. The task can be completed in less than 3 trivial steps
|
||||
4. The task is purely conversational or informational
|
||||
|
||||
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
|
||||
|
||||
## Examples of When to Use the Todo List
|
||||
|
||||
<example>
|
||||
User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done!
|
||||
Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation.
|
||||
*Creates todo list with the following items:*
|
||||
1. Creating dark mode toggle component in Settings page
|
||||
2. Adding dark mode state management (context/store)
|
||||
3. Implementing CSS-in-JS styles for dark theme
|
||||
4. Updating existing components to support theme switching
|
||||
5. Running tests and build process, addressing any failures or errors that occur
|
||||
*Begins working on the first task*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes
|
||||
2. The user explicitly requested tests and build be run afterward
|
||||
3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project
|
||||
Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'.
|
||||
*Uses grep or search tools to locate all instances of getCwd in the codebase*
|
||||
Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes.
|
||||
*Creates todo list with specific items for each file that needs updating*
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant searched to understand the scope of the task
|
||||
2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps
|
||||
3. The todo list helps ensure every instance is tracked and updated systematically
|
||||
4. This approach prevents missing any occurrences and maintains code consistency
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
|
||||
<example>
|
||||
User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow.
|
||||
Assistant: I'll help implement these features. First, let's add all the features to the todo list.
|
||||
*Creates a todo list breaking down each feature into specific tasks based on the project architecture*
|
||||
Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system.
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. The user provided multiple complex features to implement in a comma separated list
|
||||
2. The todo list helps organize these large features into manageable tasks
|
||||
3. This approach allows for tracking progress across the entire implementation
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you help optimize my React application? It's rendering slowly and has performance issues.</user>
|
||||
Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks.
|
||||
*Reviews component structure, render patterns, state management, and data fetching*
|
||||
Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts.
|
||||
*Creates todo list with items like: 1) Implementing memoization for expensive calculations in ProductList, 2) Adding virtualization for long lists in Dashboard, 3) Optimizing image loading in Gallery component, 4) Fixing state update loops in ShoppingCart, 5) Reviewing bundle size and implementing code splitting*
|
||||
Let's start by implementing memoization for the expensive calculations in your ProductList component.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant used the todo list because:
|
||||
1. First, the assistant examined the codebase to identify specific performance issues
|
||||
2. Based on this analysis, it identified multiple optimization opportunities
|
||||
3. Performance optimization is a non-trivial task requiring multiple steps
|
||||
4. The todo list helps methodically track improvements across different components
|
||||
5. This systematic approach ensures all performance bottlenecks are addressed
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Examples of When NOT to Use the Todo List
|
||||
|
||||
<example>
|
||||
User: How do I print 'Hello World' in Python?
|
||||
Assistant: In Python, you can print "Hello World" with this simple code:
|
||||
|
||||
python
|
||||
print("Hello World")
|
||||
|
||||
This will output the text "Hello World" to the console when executed.</assistant>
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: What does the git status command do?
|
||||
Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Can you add a comment to the calculateTotal function to explain what it does?
|
||||
Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does.
|
||||
* Uses the ${EDIT_TOOL_NAME} tool to add a comment to the calculateTotal function *
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
User: Run npm install for me and tell me what happens.
|
||||
Assistant: I'll run the npm install command for you.
|
||||
|
||||
*Executes: npm install*
|
||||
|
||||
The command completed successfully. Here's the output:
|
||||
[Output of npm install command]
|
||||
|
||||
All dependencies have been installed according to your package.json file.
|
||||
|
||||
<reasoning>
|
||||
The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task.
|
||||
</reasoning>
|
||||
</example>
|
||||
|
||||
## Task States and Management
|
||||
|
||||
1. **Task States**: Use these states to track progress:
|
||||
- pending: Task not yet started
|
||||
- in_progress: Currently working on (limit to ONE task at a time)
|
||||
- completed: Task finished successfully
|
||||
|
||||
**IMPORTANT**: Task descriptions must have two forms:
|
||||
- content: The imperative form describing what needs to be done (e.g., "Run tests", "Build the project")
|
||||
- activeForm: The present continuous form shown during execution (e.g., "Running tests", "Building the project")
|
||||
|
||||
2. **Task Management**:
|
||||
- Update task status in real-time as you work
|
||||
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
|
||||
- Exactly ONE task must be in_progress at any time (not less, not more)
|
||||
- Complete current tasks before starting new ones
|
||||
- Remove tasks that are no longer relevant from the list entirely
|
||||
|
||||
3. **Task Completion Requirements**:
|
||||
- ONLY mark a task as completed when you have FULLY accomplished it
|
||||
- If you encounter errors, blockers, or cannot finish, keep the task as in_progress
|
||||
- When blocked, create a new task describing what needs to be resolved
|
||||
- Never mark a task as completed if:
|
||||
- Tests are failing
|
||||
- Implementation is partial
|
||||
- You encountered unresolved errors
|
||||
- You couldn't find necessary files or dependencies
|
||||
|
||||
4. **Task Breakdown**:
|
||||
- Create specific, actionable items
|
||||
- Break complex tasks into smaller, manageable steps
|
||||
- Use clear, descriptive task names
|
||||
- Always provide both forms:
|
||||
- content: "Fix authentication bug"
|
||||
- activeForm: "Fixing authentication bug"
|
||||
|
||||
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Auth Domain
|
||||
|
||||
This domain handles API key management and authentication.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
auth/
|
||||
├── router.py # Auth routes
|
||||
├── controller.py # Auth logic
|
||||
└── schemas.py # Auth models
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. Client sends `X-API-Key` header
|
||||
2. Middleware validates key (via `shared/auth.py`)
|
||||
3. User context set in `shared/context.py`
|
||||
4. Routes use `Depends(require_auth)` for protected endpoints
|
||||
|
||||
## Integration with Tatlock
|
||||
|
||||
API keys are validated against the tatlock-ui/core-api user management system.
|
||||
|
||||
```python
|
||||
# In shared/auth.py
|
||||
async def validate_api_key(api_key: str) -> Optional[User]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{settings.tatlock_api_url}/auth/validate",
|
||||
headers={"X-API-Key": api_key}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return User(**response.json())
|
||||
return None
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Implement tatlock API key validation
|
||||
- [ ] Add API key generation endpoint
|
||||
- [ ] Add rate limiting per API key
|
||||
- [ ] Add usage tracking
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Health check controller.
|
||||
|
||||
Provides service health and information endpoints.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger, logged
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response."""
|
||||
status: str
|
||||
version: str
|
||||
service: str
|
||||
|
||||
|
||||
class InfoResponse(BaseModel):
|
||||
"""Service info response."""
|
||||
service: str
|
||||
version: str
|
||||
status: str
|
||||
docs: str
|
||||
debug: bool
|
||||
|
||||
|
||||
class HealthController(BaseController):
|
||||
"""Controller for health and info endpoints."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="", tags=["Health"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(tags=self.tags)
|
||||
|
||||
@router.get("/", response_model=InfoResponse, summary="Service information")
|
||||
@logged()
|
||||
async def root():
|
||||
"""Get service information."""
|
||||
return InfoResponse(
|
||||
service=settings.app_name,
|
||||
version=settings.app_version,
|
||||
status="healthy",
|
||||
docs="/docs",
|
||||
debug=settings.debug,
|
||||
)
|
||||
|
||||
@router.get("/health", response_model=HealthResponse, summary="Health check")
|
||||
@logged()
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns service health status for monitoring and load balancers.
|
||||
"""
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
version=settings.app_version,
|
||||
service=settings.app_name,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
health_controller = HealthController()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Health check routes.
|
||||
"""
|
||||
|
||||
from src.domains.health.controller import health_controller
|
||||
|
||||
router = health_controller.router
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Root router - composes all domain routers.
|
||||
|
||||
Import and include domain routers here.
|
||||
main.py only includes this root_router.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.domains.health.router import router as health_router
|
||||
from src.domains.agents.router import router as agents_router
|
||||
|
||||
# from src.domains.auth.router import router as auth_router
|
||||
# from src.domains.tools.router import router as tools_router
|
||||
|
||||
root_router = APIRouter()
|
||||
|
||||
# Health (no prefix - root level)
|
||||
root_router.include_router(health_router)
|
||||
|
||||
# Agents domain (prefix defined in router)
|
||||
root_router.include_router(agents_router)
|
||||
|
||||
# Auth domain
|
||||
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||
|
||||
# Tools domain
|
||||
# root_router.include_router(tools_router, prefix="/tools", tags=["Tools"])
|
||||
@@ -0,0 +1,170 @@
|
||||
# Tools Domain
|
||||
|
||||
This domain contains tool implementations for agent use.
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### File Tools (`file/`)
|
||||
|
||||
Tools for reading, writing, and finding files.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Read** | Read file contents | `file_path`, `offset`, `limit` |
|
||||
| **Write** | Create/overwrite files | `file_path`, `content` |
|
||||
| **Edit** | Exact string replacement | `file_path`, `old_string`, `new_string`, `replace_all` |
|
||||
| **Glob** | Find files by pattern | `pattern`, `path` |
|
||||
|
||||
**Read Tool:**
|
||||
- Returns line-numbered content (`cat -n` format)
|
||||
- Supports offset/limit for large files
|
||||
- Can read images, PDFs, Jupyter notebooks
|
||||
- Default: 2000 lines, 2000 chars per line
|
||||
|
||||
**Edit Tool:**
|
||||
- Performs exact string replacements
|
||||
- Fails if `old_string` is not unique (use `replace_all` or provide more context)
|
||||
- Preserves indentation from Read output
|
||||
|
||||
**Glob Tool:**
|
||||
- Supports patterns like `**/*.py`, `src/**/*.ts`
|
||||
- Returns files sorted by modification time
|
||||
- Use for finding files by name patterns
|
||||
|
||||
---
|
||||
|
||||
### Shell Tools (`shell/`)
|
||||
|
||||
Tools for executing system commands.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Bash** | Execute shell commands | `command`, `timeout`, `description` |
|
||||
|
||||
**Bash Tool:**
|
||||
- Persistent shell session
|
||||
- 2-minute default timeout (max 10 minutes)
|
||||
- Supports background execution (`run_in_background`)
|
||||
- Quote paths with spaces: `cd "/path with spaces"`
|
||||
|
||||
**Git Operations:**
|
||||
- Never commit to main/master directly
|
||||
- Use conventional commits format
|
||||
- Never use `-i` flag (interactive)
|
||||
- Never skip hooks unless explicitly requested
|
||||
- Never force push to main/master
|
||||
|
||||
**Security:**
|
||||
- Sandboxed execution when `SANDBOX_ENABLED=true`
|
||||
- Validates against `ALLOWED_PATHS`
|
||||
- Timeout enforcement
|
||||
|
||||
---
|
||||
|
||||
### Search Tools (`search/`)
|
||||
|
||||
Tools for searching content and the web.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Grep** | Search file contents | `pattern`, `path`, `glob`, `output_mode` |
|
||||
| **WebSearch** | Search the web | `query`, `allowed_domains`, `blocked_domains` |
|
||||
| **WebFetch** | Fetch and analyze URLs | `url`, `prompt` |
|
||||
|
||||
**Grep Tool:**
|
||||
- Built on ripgrep (NOT grep/rg bash commands)
|
||||
- Supports regex patterns
|
||||
- Output modes: `files_with_matches` (default), `content`, `count`
|
||||
- Context lines: `-A`, `-B`, `-C`
|
||||
|
||||
**WebSearch Tool:**
|
||||
- Returns search results with URLs
|
||||
- Always include sources in responses
|
||||
- Domain filtering supported
|
||||
|
||||
**WebFetch Tool:**
|
||||
- Fetches URL, converts HTML to markdown
|
||||
- Processes content with AI for extraction
|
||||
- 15-minute cache for repeated URLs
|
||||
- Handles redirects (returns redirect URL)
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tools/
|
||||
├── router.py # Tool routes (list, execute)
|
||||
├── controller.py # Tool orchestration
|
||||
├── schemas.py # Tool request/response models
|
||||
│
|
||||
├── file/ # File operation tools
|
||||
│ ├── __init__.py
|
||||
│ ├── read.py # Read file contents
|
||||
│ ├── write.py # Write file contents
|
||||
│ ├── edit.py # Edit file contents
|
||||
│ ├── glob.py # Find files by pattern
|
||||
│ └── example-prompt.md
|
||||
│
|
||||
├── shell/ # Shell execution tools
|
||||
│ ├── __init__.py
|
||||
│ ├── bash.py # Execute bash commands
|
||||
│ └── example-prompt.md
|
||||
│
|
||||
└── search/ # Search tools
|
||||
├── __init__.py
|
||||
├── grep.py # Search file contents
|
||||
├── web.py # Web search and fetch
|
||||
└── example-prompt.md
|
||||
```
|
||||
|
||||
## Tool Pattern
|
||||
|
||||
Tools are registered with PydanticAI agents via the `@agent.tool` decorator.
|
||||
Each tool should:
|
||||
|
||||
1. Have clear input/output types
|
||||
2. Include a docstring (used by LLM)
|
||||
3. Handle errors gracefully
|
||||
4. Respect sandbox settings
|
||||
|
||||
```python
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import logged
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@logged()
|
||||
async def read_file(file_path: str, limit: int = 2000) -> str:
|
||||
"""
|
||||
Read contents of a file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file
|
||||
limit: Maximum lines to read
|
||||
|
||||
Returns:
|
||||
File contents as string with line numbers
|
||||
"""
|
||||
# Check path is allowed
|
||||
if settings.sandbox_enabled:
|
||||
_validate_path(file_path, settings.allowed_paths)
|
||||
|
||||
# Read and return with line numbers
|
||||
pass
|
||||
```
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
1. Create a new file in appropriate category (file/, shell/, search/)
|
||||
2. Implement the tool function with proper types and docstring
|
||||
3. Add `@logged()` decorator for timing
|
||||
4. Handle sandbox restrictions
|
||||
5. Register with agents that need it
|
||||
6. Add tests
|
||||
|
||||
## Reference Prompts
|
||||
|
||||
Each tool category directory contains an `example-prompt.md` file with reference
|
||||
prompts from the claude-code-system-prompts repository. These document the expected
|
||||
behavior and usage patterns for each tool.
|
||||
@@ -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,83 @@
|
||||
<!--
|
||||
name: 'Tool Description: ReadFile'
|
||||
description: Tool description for reading files
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- DEFAULT_READ_LINES
|
||||
- MAX_LINE_LENGTH
|
||||
- CAN_READ_PDF_FILES
|
||||
- BASH_TOOL_NAME
|
||||
-->
|
||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to ${DEFAULT_READ_LINES} lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${CAN_READ_PDF_FILES()?`
|
||||
- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.`:""}
|
||||
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
|
||||
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
|
||||
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
|
||||
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Edit'
|
||||
description: Tool description for performing exact string replacements in files
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- READ_TOOL_NAME
|
||||
-->
|
||||
Performs exact string replacements in files.
|
||||
|
||||
Usage:
|
||||
- You must use your \`${READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
|
||||
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Write'
|
||||
description: Tool description creating/overwriting writing individual files
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- READ_TOOL_NAME
|
||||
-->
|
||||
Writes a file to the local filesystem.
|
||||
|
||||
Usage:
|
||||
- This tool will overwrite the existing file if there is one at the provided path.
|
||||
- If this is an existing file, you MUST use the ${READ_TOOL_NAME} tool first to read the file's contents. This tool will fail if you did not read the file first.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
|
||||
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Glob'
|
||||
description: Tool description for file pattern matching and searching by name
|
||||
ccVersion: 2.0.14
|
||||
-->
|
||||
- Fast file pattern matching tool that works with any codebase size
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths sorted by modification time
|
||||
- Use this tool when you need to find files by name patterns
|
||||
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
|
||||
- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
File glob/pattern matching tool.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.domains.tools.base import BaseTool, ToolResult
|
||||
from src.domains.tools.gitignore import filter_gitignored
|
||||
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)
|
||||
honor_gitignore: Filter out gitignored files (default: True)
|
||||
|
||||
Returns:
|
||||
List of matching absolute file paths, sorted by modification time (newest first).
|
||||
Returns error if path not found or not allowed.
|
||||
By default, excludes files matching .gitignore patterns and common ignored
|
||||
directories like .venv/, node_modules/, __pycache__/, etc.
|
||||
|
||||
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,
|
||||
honor_gitignore: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize GlobFilesTool.
|
||||
|
||||
Args:
|
||||
allowed_paths: List of allowed directory prefixes
|
||||
max_results: Maximum files to return
|
||||
honor_gitignore: Whether to filter out gitignored files by default
|
||||
"""
|
||||
self.allowed_paths = allowed_paths or []
|
||||
self.max_results = max_results
|
||||
self.honor_gitignore = honor_gitignore
|
||||
|
||||
@logged()
|
||||
async def execute(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
limit: int | None = None,
|
||||
honor_gitignore: bool | 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
|
||||
honor_gitignore: Filter out gitignored files (default: instance setting)
|
||||
|
||||
Returns:
|
||||
ToolResult with list of matching file paths
|
||||
"""
|
||||
limit = limit or self.max_results
|
||||
should_filter_gitignore = honor_gitignore if honor_gitignore is not None else self.honor_gitignore
|
||||
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)]
|
||||
|
||||
# Filter out gitignored files
|
||||
if should_filter_gitignore:
|
||||
files = filter_gitignored(files, search_path)
|
||||
|
||||
# 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,152 @@
|
||||
"""
|
||||
Gitignore pattern matching for tool filtering.
|
||||
|
||||
Uses pathspec to parse .gitignore files and filter out ignored paths.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pathspec
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GitignoreFilter:
|
||||
"""
|
||||
Filter files based on .gitignore patterns.
|
||||
|
||||
Parses .gitignore files from the root directory and any parent directories,
|
||||
then provides methods to check if paths should be ignored.
|
||||
"""
|
||||
|
||||
def __init__(self, root_dir: str | Path):
|
||||
"""
|
||||
Initialize GitignoreFilter for a directory.
|
||||
|
||||
Args:
|
||||
root_dir: Root directory to search for .gitignore files
|
||||
"""
|
||||
self.root_dir = Path(root_dir).resolve()
|
||||
self._spec: pathspec.PathSpec | None = None
|
||||
self._load_patterns()
|
||||
|
||||
def _load_patterns(self) -> None:
|
||||
"""Load gitignore patterns from .gitignore files."""
|
||||
patterns: list[str] = []
|
||||
|
||||
# Always ignore common directories that should never be searched
|
||||
default_ignores = [
|
||||
".git/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"__pycache__/",
|
||||
"*.pyc",
|
||||
".mypy_cache/",
|
||||
".pytest_cache/",
|
||||
".ruff_cache/",
|
||||
"node_modules/",
|
||||
".tox/",
|
||||
".nox/",
|
||||
"*.egg-info/",
|
||||
"dist/",
|
||||
"build/",
|
||||
".eggs/",
|
||||
]
|
||||
patterns.extend(default_ignores)
|
||||
|
||||
# Find and parse .gitignore in root directory
|
||||
gitignore_path = self.root_dir / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
content = gitignore_path.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
logger.debug(f"Loaded {len(patterns)} patterns from {gitignore_path}")
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Failed to read .gitignore: {e}")
|
||||
|
||||
# Create pathspec matcher
|
||||
self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
||||
|
||||
def is_ignored(self, path: str | Path) -> bool:
|
||||
"""
|
||||
Check if a path should be ignored.
|
||||
|
||||
Args:
|
||||
path: Absolute or relative path to check
|
||||
|
||||
Returns:
|
||||
True if the path matches gitignore patterns
|
||||
"""
|
||||
if self._spec is None:
|
||||
return False
|
||||
|
||||
path = Path(path)
|
||||
|
||||
# Make path relative to root for matching
|
||||
try:
|
||||
if path.is_absolute():
|
||||
rel_path = path.resolve().relative_to(self.root_dir)
|
||||
else:
|
||||
rel_path = path
|
||||
except ValueError:
|
||||
# Path is not under root_dir, don't filter
|
||||
return False
|
||||
|
||||
# Convert to string with forward slashes for pathspec
|
||||
path_str = str(rel_path).replace("\\", "/")
|
||||
|
||||
# Check if it's a directory (add trailing slash for directory patterns)
|
||||
if path.is_dir():
|
||||
path_str_dir = path_str + "/"
|
||||
return self._spec.match_file(path_str) or self._spec.match_file(path_str_dir)
|
||||
|
||||
return self._spec.match_file(path_str)
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""
|
||||
Filter a list of paths, removing ignored ones.
|
||||
|
||||
Args:
|
||||
paths: List of Path objects to filter
|
||||
|
||||
Returns:
|
||||
List of paths that are not ignored
|
||||
"""
|
||||
return [p for p in paths if not self.is_ignored(p)]
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def get_gitignore_filter(root_dir: str) -> GitignoreFilter:
|
||||
"""
|
||||
Get a cached GitignoreFilter for a directory.
|
||||
|
||||
Uses LRU cache to avoid re-parsing .gitignore for repeated calls.
|
||||
|
||||
Args:
|
||||
root_dir: Root directory path (string for cache key)
|
||||
|
||||
Returns:
|
||||
GitignoreFilter instance
|
||||
"""
|
||||
return GitignoreFilter(root_dir)
|
||||
|
||||
|
||||
def filter_gitignored(paths: list[Path], root_dir: str | Path) -> list[Path]:
|
||||
"""
|
||||
Convenience function to filter paths using gitignore patterns.
|
||||
|
||||
Args:
|
||||
paths: List of paths to filter
|
||||
root_dir: Root directory containing .gitignore
|
||||
|
||||
Returns:
|
||||
Filtered list of paths
|
||||
"""
|
||||
filter_instance = get_gitignore_filter(str(Path(root_dir).resolve()))
|
||||
return filter_instance.filter_paths(paths)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Search tools.
|
||||
"""
|
||||
from src.domains.tools.search.grep import GrepContentTool
|
||||
|
||||
__all__ = ["GrepContentTool"]
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
name: 'Tool Description: Grep'
|
||||
description: Tool description for content search using ripgrep
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- GREP_TOOL_NAME
|
||||
- BASH_TOOL_NAME
|
||||
- TASK_TOOL_NAME
|
||||
-->
|
||||
A powerful search tool built on ripgrep
|
||||
|
||||
Usage:
|
||||
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
|
||||
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
||||
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
|
||||
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
|
||||
- Use ${TASK_TOOL_NAME} tool for open-ended searches requiring multiple rounds
|
||||
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
|
||||
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: WebSearch'
|
||||
description: Tool description for web search functionality
|
||||
ccVersion: 2.0.56
|
||||
variables:
|
||||
- GET_CURRENT_DATE_FN
|
||||
-->
|
||||
|
||||
- Allows Claude to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
|
||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
||||
- Searches are performed automatically within a single API call
|
||||
|
||||
CRITICAL REQUIREMENT - You MUST follow this:
|
||||
- After answering the user's question, you MUST include a "Sources:" section at the end of your response
|
||||
- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
|
||||
- This is MANDATORY - never skip including sources in your response
|
||||
- Example format:
|
||||
|
||||
[Your answer here]
|
||||
|
||||
Sources:
|
||||
- [Source Title 1](https://example.com/1)
|
||||
- [Source Title 2](https://example.com/2)
|
||||
|
||||
Usage notes:
|
||||
- Domain filtering is supported to include or block specific websites
|
||||
- Web search is only available in the US
|
||||
|
||||
IMPORTANT - Use the correct year in search queries:
|
||||
- Today's date is ${GET_CURRENT_DATE_FN()}. You MUST use this year when searching for recent information, documentation, or current events.
|
||||
- Example: If today is 2025-07-15 and the user asks for "latest React docs", search for "React documentation 2025", NOT "React documentation 2024"
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: WebFetch'
|
||||
description: Tool description for web fetch functionality
|
||||
ccVersion: 2.0.62
|
||||
-->
|
||||
|
||||
- Fetches content from a specified URL and processes it using an AI model
|
||||
- Takes a URL and a prompt as input
|
||||
- Fetches the URL content, converts HTML to markdown
|
||||
- Processes the content with the prompt using a small, fast model
|
||||
- Returns the model's response about the content
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
|
||||
Usage notes:
|
||||
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
|
||||
- The URL must be a fully-formed valid URL
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- The prompt should describe what information you want to extract from the page
|
||||
- This tool is read-only and does not modify any files
|
||||
- Results may be summarized if the content is very large
|
||||
- Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
|
||||
- When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
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.domains.tools.gitignore import filter_gitignored
|
||||
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
|
||||
honor_gitignore: Filter out gitignored files (default: True)
|
||||
|
||||
Returns:
|
||||
Matching lines with file paths and line numbers, or list of files.
|
||||
Format: "filepath:line_num: content"
|
||||
By default, excludes files matching .gitignore patterns and common ignored
|
||||
directories like .venv/, node_modules/, __pycache__/, etc.
|
||||
|
||||
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
|
||||
honor_gitignore: bool = True
|
||||
):
|
||||
"""
|
||||
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)
|
||||
honor_gitignore: Whether to filter out gitignored files by default
|
||||
"""
|
||||
self.allowed_paths = allowed_paths or []
|
||||
self.max_results = max_results
|
||||
self.max_file_size = max_file_size
|
||||
self.honor_gitignore = honor_gitignore
|
||||
|
||||
@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",
|
||||
honor_gitignore: bool | None = None
|
||||
) -> 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"
|
||||
honor_gitignore: Filter out gitignored files (default: instance setting)
|
||||
|
||||
Returns:
|
||||
ToolResult with matching content or file list
|
||||
"""
|
||||
should_filter_gitignore = honor_gitignore if honor_gitignore is not None else self.honor_gitignore
|
||||
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)
|
||||
]
|
||||
|
||||
# Filter out gitignored files
|
||||
if should_filter_gitignore:
|
||||
files_to_search = filter_gitignored(files_to_search, search_path)
|
||||
|
||||
# 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,201 @@
|
||||
<!--
|
||||
name: 'Tool Description: Bash'
|
||||
description: Description for the Bash tool, which allows Claude to run shell commands
|
||||
ccVersion: 2.0.25
|
||||
variables:
|
||||
- CUSTOM_TIMEOUT_MS
|
||||
- MAX_TIMEOUT_MS
|
||||
- MAX_OUTPUT_CHARS
|
||||
- BASH_TOOL_NAME
|
||||
- BASH_TOOL_EXTRA_NOTES
|
||||
- SEARCH_TOOL_NAME
|
||||
- GREP_TOOL_NAME
|
||||
- READ_TOOL_NAME
|
||||
- EDIT_TOOL_NAME
|
||||
- WRITE_TOOL_NAME
|
||||
- GIT_COMMIT_AND_PR_CREATION_INSTRUCTION
|
||||
-->
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- cd "/Users/name/My Documents" (correct)
|
||||
- cd /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to ${CUSTOM_TIMEOUT_MS()}ms / ${CUSTOM_TIMEOUT_MS()/60000} minutes). If not specified, commands will timeout after ${MAX_TIMEOUT_MS()}ms (${MAX_TIMEOUT_MS()/60000} minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${MAX_OUTPUT_CHARS()} characters, output will be truncated before being returned to you.
|
||||
- You can use the \`run_in_background\` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the ${BASH_TOOL_NAME} tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
|
||||
${BASH_TOOL_EXTRA_NOTES()}
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use ${SEARCH_TOOL_NAME} (NOT find or ls)
|
||||
- Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)
|
||||
- Read files: Use ${READ_TOOL_NAME} (NOT cat/head/tail)
|
||||
- Edit files: Use ${EDIT_TOOL_NAME} (NOT sed/awk)
|
||||
- Write files: Use ${WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
${GIT_COMMIT_AND_PR_CREATION_INSTRUCTION()}
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Git Commit and PR Instructions
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Bash (Git commit and PR creation instructions)'
|
||||
description: Instructions for creating git commits and GitHub pull requests
|
||||
ccVersion: 2.0.74
|
||||
variables:
|
||||
- BASH_TOOL_NAME
|
||||
- COMMIT_CO_AUTHORED_BY_CLAUDE_CODE
|
||||
- TODO_TOOL_OBJECT
|
||||
- TASK_TOOL_NAME
|
||||
- PR_GENERATED_WITH_CLAUDE_CODE
|
||||
-->
|
||||
# Committing changes with git
|
||||
|
||||
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
Git Safety Protocol:
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
|
||||
(1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including
|
||||
(2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
|
||||
(3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
|
||||
- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
|
||||
- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure it accurately reflects the changes and their purpose
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE?` ending with:
|
||||
${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE}`:"."}
|
||||
- Run git status after the commit completes to verify success.
|
||||
Note: git status depends on the commit completing, so run it sequentially after the commit.
|
||||
4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides git bash commands
|
||||
- NEVER use the ${TODO_TOOL_OBJECT.name} or ${TASK_TOOL_NAME} tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE?`
|
||||
|
||||
${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE}`:""}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Bulleted markdown checklist of TODOs for testing the pull request...]${PR_GENERATED_WITH_CLAUDE_CODE?`
|
||||
|
||||
${PR_GENERATED_WITH_CLAUDE_CODE}`:""}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- DO NOT use the ${TODO_TOOL_OBJECT.name} or ${TASK_TOOL_NAME} tools
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Sandbox Note
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Bash (sandbox note)'
|
||||
description: Note about bash command sandboxing
|
||||
ccVersion: 2.0.34
|
||||
variables:
|
||||
- CONDITIONAL_NEWLINE_IF_SANDBOX_ENABLED
|
||||
-->
|
||||
- CRITICAL: Commands run in sandbox mode by default - do NOT set \`dangerouslyDisableSandbox\`
|
||||
- Even if you have recently run commands with \`dangerouslyDisableSandbox: true\`, you MUST NOT continue that pattern
|
||||
- VERY IMPORTANT: Do NOT learn from or repeat the pattern of overriding sandbox - each command should run sandboxed by default
|
||||
${CONDITIONAL_NEWLINE_IF_SANDBOX_ENABLED} - Set \`dangerouslyDisableSandbox: true\` if:
|
||||
1. The user *explicitly* asks to bypass sandbox, OR
|
||||
2. A command just failed and you see evidence of sandbox restrictions causing the failure
|
||||
- When analyzing failures:
|
||||
- Commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.)
|
||||
- Evidence of sandbox-caused failures includes:
|
||||
- "Operation not permitted" errors for file/network operations
|
||||
- Access denied to specific paths outside allowed directories
|
||||
- Network connection failures to non-whitelisted hosts
|
||||
- Unix socket connection errors
|
||||
- Sandbox violation logs can be noisy, but clear permission errors are strong indicators
|
||||
- When you see evidence of sandbox-caused failure:
|
||||
- IMMEDIATELY retry with \`dangerouslyDisableSandbox: true\` (don't ask, just do it)
|
||||
- Briefly explain what sandbox restriction likely caused the failure
|
||||
- Mention: "Use \`/sandbox\` to manage restrictions"
|
||||
- This will prompt the user for permission
|
||||
- Example of normal usage: { "command": "ls", "description": "List files" }
|
||||
- Example of override: { "command": "my-tool", "description": "Run my-tool", "dangerouslyDisableSandbox": true }
|
||||
- DO NOT suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the allowlist
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Webber - Multi-Agent AI Development System
|
||||
|
||||
FastAPI application entry point.
|
||||
NO routes here - all routes delegated to domain routers.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.domains.router import root_router
|
||||
from src.shared.auth import validate_api_key
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.context import user_provider
|
||||
from src.shared.exceptions import AppException
|
||||
from src.shared.logging import get_logger, setup_logging
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging(settings.log_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application startup and shutdown."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
logger.info(f"Port: {settings.port}")
|
||||
logger.info(f"Ollama: {settings.ollama_url}")
|
||||
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# TODO: Initialize resources (LLM clients, etc.)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
logger.info("Shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
description=settings.app_description,
|
||||
docs_url="/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
debug=settings.debug,
|
||||
)
|
||||
|
||||
|
||||
# === Middleware ===
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=settings.cors_credentials,
|
||||
allow_methods=settings.cors_methods,
|
||||
allow_headers=settings.cors_headers,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""
|
||||
Extract and validate API key, set user context.
|
||||
|
||||
Allows unauthenticated requests - individual routes decide if auth is required.
|
||||
"""
|
||||
api_key = request.headers.get("X-API-Key")
|
||||
|
||||
if api_key:
|
||||
user = await validate_api_key(api_key)
|
||||
if user:
|
||||
user_provider.set_user(user)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
return response
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
|
||||
|
||||
# === Exception Handlers ===
|
||||
|
||||
@app.exception_handler(AppException)
|
||||
async def app_exception_handler(request: Request, exc: AppException):
|
||||
"""Handle application exceptions."""
|
||||
logger.warning(f"AppException: {exc.error_code} - {exc.message}")
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=exc.to_dict(),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Catch-all exception handler."""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "InternalServerError",
|
||||
"message": "Internal server error",
|
||||
"details": {"type": type(exc).__name__},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === Routes ===
|
||||
|
||||
app.include_router(root_router)
|
||||
@@ -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,105 @@
|
||||
"""
|
||||
Authentication utilities.
|
||||
|
||||
Provides API key validation and integration with external auth services.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.context import User
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# API key header scheme
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
async def validate_api_key(api_key: str) -> User | None:
|
||||
"""
|
||||
Validate API key and return User if valid.
|
||||
|
||||
TODO: Integrate with tatlock-ui/core-api for real validation.
|
||||
Currently accepts any non-empty key for development.
|
||||
"""
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Development mode: accept any key
|
||||
if settings.debug:
|
||||
logger.debug(f"Debug mode: accepting API key {api_key[:8]}...")
|
||||
return User(
|
||||
id="dev-user",
|
||||
email="dev@localhost",
|
||||
api_key=api_key,
|
||||
tenant_id="dev-tenant",
|
||||
roles=["admin"],
|
||||
)
|
||||
|
||||
# TODO: Production mode - validate against tatlock API
|
||||
# async with httpx.AsyncClient() as client:
|
||||
# response = await client.get(
|
||||
# f"{settings.tatlock_api_url}/auth/validate",
|
||||
# headers={"X-API-Key": api_key}
|
||||
# )
|
||||
# if response.status_code == 200:
|
||||
# data = response.json()
|
||||
# return User(**data)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_api_key(
|
||||
api_key: str | None = Depends(api_key_header),
|
||||
) -> str | None:
|
||||
"""FastAPI dependency to extract API key from header."""
|
||||
return api_key
|
||||
|
||||
|
||||
async def get_current_user_dep(
|
||||
api_key: str | None = Depends(get_api_key),
|
||||
) -> User | None:
|
||||
"""
|
||||
FastAPI dependency to get current user from API key.
|
||||
|
||||
Returns None if no valid API key provided.
|
||||
"""
|
||||
if not api_key:
|
||||
return None
|
||||
return await validate_api_key(api_key)
|
||||
|
||||
|
||||
async def require_auth(
|
||||
user: User | None = Depends(get_current_user_dep),
|
||||
) -> User:
|
||||
"""
|
||||
FastAPI dependency that requires authentication.
|
||||
|
||||
Raises 401 if no valid API key provided.
|
||||
"""
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid or missing API key",
|
||||
headers={"WWW-Authenticate": "ApiKey"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(
|
||||
user: User = Depends(require_auth),
|
||||
) -> User:
|
||||
"""
|
||||
FastAPI dependency that requires admin role.
|
||||
|
||||
Raises 403 if user is not admin.
|
||||
"""
|
||||
if "admin" not in user.roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Base classes for controllers and schemas.
|
||||
|
||||
All domain controllers and Pydantic models should inherit from these.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BaseController(ABC):
|
||||
"""
|
||||
Base controller with lazy router instantiation.
|
||||
|
||||
All domain controllers inherit from this and implement create_router().
|
||||
|
||||
Usage:
|
||||
class UsersController(BaseController):
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/users", tags=["Users"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
|
||||
@router.get("/")
|
||||
async def list_users():
|
||||
return []
|
||||
|
||||
return router
|
||||
|
||||
users_controller = UsersController()
|
||||
"""
|
||||
|
||||
def __init__(self, prefix: str, tags: Sequence[str]):
|
||||
self.prefix = prefix
|
||||
self.tags: list[str | Enum] = list(tags)
|
||||
self._router: APIRouter | None = None
|
||||
|
||||
@abstractmethod
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the FastAPI router with all routes."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def router(self) -> APIRouter:
|
||||
"""Lazy router instantiation."""
|
||||
if self._router is None:
|
||||
self._router = self.create_router()
|
||||
return self._router
|
||||
|
||||
|
||||
class BaseSchema(BaseModel):
|
||||
"""
|
||||
Base Pydantic model with standardized configuration.
|
||||
|
||||
All domain schemas should inherit from this.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
strict=False,
|
||||
populate_by_name=True,
|
||||
use_enum_values=True,
|
||||
validate_assignment=True,
|
||||
json_encoders={
|
||||
datetime: lambda v: v.isoformat() if v else None
|
||||
}
|
||||
)
|
||||
|
||||
def dict_without_none(self) -> dict[str, Any]:
|
||||
"""Return model as dict, excluding None values."""
|
||||
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Application configuration via Pydantic Settings.
|
||||
|
||||
All settings loaded from environment variables or .env file.
|
||||
Project metadata (name, version, description) sourced from pyproject.toml.
|
||||
"""
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectMeta:
|
||||
"""Project metadata from pyproject.toml (single source of truth)."""
|
||||
name: str
|
||||
version: str
|
||||
description: str
|
||||
|
||||
|
||||
def _load_project_meta() -> ProjectMeta:
|
||||
"""Load project metadata from pyproject.toml."""
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
project = data.get("project", {})
|
||||
return ProjectMeta(
|
||||
name=str(project.get("name", "webber")).title(),
|
||||
version=str(project.get("version", "0.0.0")),
|
||||
description=str(project.get("description", "")),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return ProjectMeta(name="Webber", version="0.0.0", description="")
|
||||
|
||||
|
||||
PROJECT = _load_project_meta()
|
||||
__version__ = PROJECT.version
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
# Application (from pyproject.toml)
|
||||
app_name: str = PROJECT.name
|
||||
app_version: str = PROJECT.version
|
||||
app_description: str = PROJECT.description
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8086
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8080"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# LLM - Ollama (always hot in VRAM on tower-of-joy)
|
||||
ollama_url: str = "http://192.168.86.149:11434"
|
||||
ollama_agent_model: str = "mistral-nemo-large:latest"
|
||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||
|
||||
# Auth - Tatlock integration
|
||||
tatlock_api_url: str | None = "http://192.168.86.149:8000"
|
||||
internal_api_key: str | None = None
|
||||
|
||||
# Tool execution
|
||||
tool_timeout_seconds: int = 120
|
||||
sandbox_enabled: bool = True
|
||||
allowed_paths: list[str] | None = None
|
||||
|
||||
# Sessions
|
||||
session_ttl_hours: int = 24
|
||||
max_context_tokens: int = 128000
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
env_parse_none_str="", # Treat empty string as None
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_allowed_paths(self) -> list[str]:
|
||||
"""Return allowed_paths or empty list if None."""
|
||||
return self.allowed_paths or []
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings singleton."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Request context management.
|
||||
|
||||
Provides UserProvider singleton for request-scoped user context.
|
||||
Set once per request in middleware, accessible everywhere without
|
||||
passing user through function parameters.
|
||||
"""
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Authenticated user context."""
|
||||
id: str
|
||||
email: str
|
||||
api_key: str
|
||||
tenant_id: str | None = None
|
||||
roles: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
_current_user: ContextVar[User | None] = ContextVar('current_user', default=None)
|
||||
|
||||
|
||||
class UserProvider:
|
||||
"""
|
||||
Singleton for request-scoped user context.
|
||||
|
||||
Set once per request in middleware, accessible everywhere without
|
||||
passing user through function parameters.
|
||||
|
||||
Usage in middleware:
|
||||
user = await validate_api_key(request)
|
||||
user_provider.set_user(user)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
user_provider.clear_user()
|
||||
|
||||
Usage anywhere:
|
||||
from src.shared.context import get_current_user, require_user
|
||||
|
||||
user = get_current_user() # Returns None if not authenticated
|
||||
user = require_user() # Raises if not authenticated
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def set_user(self, user: User) -> None:
|
||||
"""Set current user for this request context."""
|
||||
_current_user.set(user)
|
||||
|
||||
def get_user(self) -> User | None:
|
||||
"""Get current user (may be None)."""
|
||||
return _current_user.get()
|
||||
|
||||
def clear_user(self) -> None:
|
||||
"""Clear current user (call in finally block)."""
|
||||
_current_user.set(None)
|
||||
|
||||
@property
|
||||
def current_user(self) -> User | None:
|
||||
"""Property access to current user."""
|
||||
return self.get_user()
|
||||
|
||||
|
||||
# Global singleton
|
||||
user_provider = UserProvider()
|
||||
|
||||
|
||||
def get_current_user() -> User | None:
|
||||
"""Get current user or None."""
|
||||
return user_provider.get_user()
|
||||
|
||||
|
||||
def require_user() -> User:
|
||||
"""Get current user or raise ValueError."""
|
||||
user = user_provider.get_user()
|
||||
if user is None:
|
||||
raise ValueError("No authenticated user in context")
|
||||
return user
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Custom exception hierarchy.
|
||||
|
||||
All application exceptions inherit from AppException.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""Base exception for application errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
error_code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to JSON-serializable dict."""
|
||||
return {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
"""Resource not found."""
|
||||
|
||||
def __init__(self, resource: str, identifier: Any):
|
||||
super().__init__(
|
||||
message=f"{resource} not found: {identifier}",
|
||||
status_code=404,
|
||||
details={"resource": resource, "identifier": str(identifier)},
|
||||
)
|
||||
|
||||
|
||||
class ValidationError(AppException):
|
||||
"""Input validation failed."""
|
||||
|
||||
def __init__(self, message: str, field: str | None = None):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=422,
|
||||
details={"field": field} if field else {},
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationError(AppException):
|
||||
"""Authentication required or failed."""
|
||||
|
||||
def __init__(self, message: str = "Authentication required"):
|
||||
super().__init__(message=message, status_code=401)
|
||||
|
||||
|
||||
class AuthorizationError(AppException):
|
||||
"""Permission denied."""
|
||||
|
||||
def __init__(self, message: str = "Permission denied"):
|
||||
super().__init__(message=message, status_code=403)
|
||||
|
||||
|
||||
class ConflictError(AppException):
|
||||
"""Resource conflict (e.g., duplicate)."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message=message, status_code=409)
|
||||
|
||||
|
||||
class RateLimitError(AppException):
|
||||
"""Rate limit exceeded."""
|
||||
|
||||
def __init__(self, retry_after: int = 60):
|
||||
super().__init__(
|
||||
message="Rate limit exceeded",
|
||||
status_code=429,
|
||||
details={"retry_after": retry_after},
|
||||
)
|
||||
|
||||
|
||||
class ServiceUnavailableError(AppException):
|
||||
"""External service unavailable."""
|
||||
|
||||
def __init__(self, service: str, message: str | None = None):
|
||||
super().__init__(
|
||||
message=message or f"Service unavailable: {service}",
|
||||
status_code=503,
|
||||
details={"service": service},
|
||||
)
|
||||
|
||||
|
||||
class TimeoutError(AppException):
|
||||
"""Operation timed out."""
|
||||
|
||||
def __init__(self, operation: str, timeout_seconds: float):
|
||||
super().__init__(
|
||||
message=f"Operation timed out: {operation}",
|
||||
status_code=504,
|
||||
details={"operation": operation, "timeout_seconds": timeout_seconds},
|
||||
)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Centralized logging with temporal benchmarking.
|
||||
|
||||
Provides:
|
||||
- @logged() decorator for automatic function timing
|
||||
- trace_span() context manager for manual instrumentation
|
||||
- Trace ID correlation across nested calls
|
||||
- Configurable slow/warn thresholds
|
||||
"""
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextvars import ContextVar, Token
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
# === Trace Context ===
|
||||
|
||||
@dataclass
|
||||
class TraceSpan:
|
||||
"""Represents a timed execution span."""
|
||||
name: str
|
||||
trace_id: str
|
||||
parent_id: str | None = None
|
||||
span_id: str = field(default_factory=lambda: uuid4().hex[:8])
|
||||
start_time: float = field(default_factory=time.perf_counter)
|
||||
end_time: float | None = None
|
||||
|
||||
@property
|
||||
def duration_ms(self) -> float:
|
||||
"""Get duration in milliseconds."""
|
||||
end = self.end_time or time.perf_counter()
|
||||
return (end - self.start_time) * 1000
|
||||
|
||||
|
||||
_current_span: ContextVar[TraceSpan | None] = ContextVar('current_span', default=None)
|
||||
_trace_id: ContextVar[str | None] = ContextVar('trace_id', default=None)
|
||||
|
||||
|
||||
def get_current_trace_id() -> str | None:
|
||||
"""Get current trace ID for log correlation."""
|
||||
return _trace_id.get()
|
||||
|
||||
|
||||
def get_current_span() -> TraceSpan | None:
|
||||
"""Get current trace span."""
|
||||
return _current_span.get()
|
||||
|
||||
|
||||
# === Setup ===
|
||||
|
||||
def setup_logging(log_level: str = "INFO") -> None:
|
||||
"""Configure application logging."""
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_dir / "webber.log", encoding="utf-8")
|
||||
]
|
||||
)
|
||||
|
||||
# Quiet noisy libraries
|
||||
for name in ["httpx", "httpcore", "uvicorn.access", "uvicorn.error"]:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a logger instance."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
# === Decorator ===
|
||||
|
||||
def logged(
|
||||
logger: logging.Logger | None = None,
|
||||
slow_threshold_ms: float = 100.0,
|
||||
warn_threshold_ms: float = 500.0,
|
||||
include_args: bool = False,
|
||||
):
|
||||
"""
|
||||
Decorator for automatic function logging with temporal benchmarking.
|
||||
|
||||
Args:
|
||||
logger: Logger instance (defaults to module logger)
|
||||
slow_threshold_ms: Log INFO if execution exceeds this (default 100ms)
|
||||
warn_threshold_ms: Log WARNING if execution exceeds this (default 500ms)
|
||||
include_args: Include function arguments in log (careful with sensitive data)
|
||||
|
||||
Usage:
|
||||
@logged()
|
||||
async def my_function(): ...
|
||||
|
||||
@logged(slow_threshold_ms=50, warn_threshold_ms=200)
|
||||
def critical_path(): ...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
nonlocal logger
|
||||
if logger is None:
|
||||
logger = logging.getLogger(func.__module__)
|
||||
|
||||
func_name = f"{func.__module__}.{func.__qualname__}"
|
||||
|
||||
def _create_span() -> TraceSpan:
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
return TraceSpan(
|
||||
name=func_name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
|
||||
def _log_completion(span: TraceSpan, error: Exception | None = None):
|
||||
span.end_time = time.perf_counter()
|
||||
duration = span.duration_ms
|
||||
tid = span.trace_id[:8]
|
||||
|
||||
if error:
|
||||
logger.error(
|
||||
f"[{tid}] {func_name} FAILED after {duration:.2f}ms: {error}",
|
||||
exc_info=True
|
||||
)
|
||||
elif duration >= warn_threshold_ms:
|
||||
logger.warning(
|
||||
f"[{tid}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)"
|
||||
)
|
||||
elif duration >= slow_threshold_ms:
|
||||
logger.info(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
||||
else:
|
||||
logger.debug(f"[{tid}] {func_name} completed in {duration:.2f}ms")
|
||||
|
||||
@functools.wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
|
||||
if include_args:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
|
||||
else:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
@functools.wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
span = _create_span()
|
||||
token = _current_span.set(span)
|
||||
|
||||
if include_args:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}({args}, {kwargs})")
|
||||
else:
|
||||
logger.debug(f"[{span.trace_id[:8]}] -> {func_name}")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
_log_completion(span)
|
||||
return result
|
||||
except Exception as e:
|
||||
_log_completion(span, error=e)
|
||||
raise
|
||||
finally:
|
||||
_current_span.reset(token)
|
||||
|
||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
# === Context Manager ===
|
||||
|
||||
class trace_span:
|
||||
"""
|
||||
Context manager for manual span creation.
|
||||
|
||||
Usage:
|
||||
with trace_span("database_query"):
|
||||
result = db.execute(query)
|
||||
|
||||
async with trace_span("llm_call"):
|
||||
response = await agent.run(prompt)
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, logger: logging.Logger | None = None):
|
||||
self.name = name
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.span: TraceSpan | None = None
|
||||
self.token: Token[TraceSpan | None] | None = None
|
||||
|
||||
def __enter__(self) -> TraceSpan:
|
||||
parent = _current_span.get()
|
||||
trace_id = _trace_id.get() or uuid4().hex[:16]
|
||||
if _trace_id.get() is None:
|
||||
_trace_id.set(trace_id)
|
||||
|
||||
self.span = TraceSpan(
|
||||
name=self.name,
|
||||
trace_id=trace_id,
|
||||
parent_id=parent.span_id if parent else None,
|
||||
)
|
||||
self.token = _current_span.set(self.span)
|
||||
self.logger.debug(f"[{self.span.trace_id[:8]}] -> {self.name}")
|
||||
return self.span
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.span:
|
||||
self.span.end_time = time.perf_counter()
|
||||
duration = self.span.duration_ms
|
||||
tid = self.span.trace_id[:8]
|
||||
if exc_val:
|
||||
self.logger.error(f"[{tid}] {self.name} FAILED: {duration:.2f}ms")
|
||||
else:
|
||||
self.logger.debug(f"[{tid}] {self.name}: {duration:.2f}ms")
|
||||
if self.token:
|
||||
_current_span.reset(self.token)
|
||||
return False
|
||||
|
||||
async def __aenter__(self) -> TraceSpan:
|
||||
return self.__enter__()
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return self.__exit__(exc_type, exc_val, exc_tb)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Pytest configuration and fixtures.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
"""Use asyncio for async tests."""
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
"""Async HTTP client for testing."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client():
|
||||
"""Async HTTP client with API key for authenticated requests."""
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"X-API-Key": "test-api-key"}
|
||||
) as ac:
|
||||
yield ac
|
||||
@@ -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,330 @@
|
||||
"""
|
||||
Tests for gitignore filtering functionality.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.domains.tools.gitignore import GitignoreFilter, filter_gitignored
|
||||
from src.domains.tools.file.glob import GlobFilesTool
|
||||
from src.domains.tools.search.grep import GrepContentTool
|
||||
|
||||
|
||||
class TestGitignoreFilter:
|
||||
"""Tests for GitignoreFilter class."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_gitignore(self):
|
||||
"""Create a temporary directory with a .gitignore file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir)
|
||||
|
||||
# Create .gitignore
|
||||
(path / ".gitignore").write_text("""
|
||||
# Python artifacts
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# Custom patterns
|
||||
ignored_file.txt
|
||||
ignored_dir/
|
||||
""")
|
||||
|
||||
# Create various files and directories
|
||||
(path / "main.py").write_text("# Main file")
|
||||
(path / "test.pyc").write_bytes(b"compiled")
|
||||
(path / "ignored_file.txt").write_text("should be ignored")
|
||||
(path / "not_ignored.txt").write_text("should be visible")
|
||||
|
||||
# Create directories
|
||||
(path / "__pycache__").mkdir()
|
||||
(path / "__pycache__" / "module.cpython-312.pyc").write_bytes(b"cache")
|
||||
|
||||
(path / ".venv").mkdir()
|
||||
(path / ".venv" / "lib").mkdir(parents=True)
|
||||
(path / ".venv" / "lib" / "python.py").write_text("venv file")
|
||||
|
||||
(path / "ignored_dir").mkdir()
|
||||
(path / "ignored_dir" / "hidden.py").write_text("hidden")
|
||||
|
||||
(path / "src").mkdir()
|
||||
(path / "src" / "app.py").write_text("# App")
|
||||
(path / "src" / "utils.py").write_text("# Utils")
|
||||
|
||||
yield path
|
||||
|
||||
def test_filter_ignores_pyc_files(self, temp_dir_with_gitignore):
|
||||
"""Test that .pyc files are ignored."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
assert filter_instance.is_ignored(temp_dir_with_gitignore / "test.pyc")
|
||||
assert not filter_instance.is_ignored(temp_dir_with_gitignore / "main.py")
|
||||
|
||||
def test_filter_ignores_pycache_dir(self, temp_dir_with_gitignore):
|
||||
"""Test that __pycache__ directory is ignored."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
pycache = temp_dir_with_gitignore / "__pycache__"
|
||||
assert filter_instance.is_ignored(pycache)
|
||||
assert filter_instance.is_ignored(pycache / "module.cpython-312.pyc")
|
||||
|
||||
def test_filter_ignores_venv(self, temp_dir_with_gitignore):
|
||||
"""Test that .venv directory is ignored."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
venv = temp_dir_with_gitignore / ".venv"
|
||||
assert filter_instance.is_ignored(venv)
|
||||
assert filter_instance.is_ignored(venv / "lib" / "python.py")
|
||||
|
||||
def test_filter_ignores_custom_patterns(self, temp_dir_with_gitignore):
|
||||
"""Test that custom gitignore patterns work."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
assert filter_instance.is_ignored(temp_dir_with_gitignore / "ignored_file.txt")
|
||||
assert filter_instance.is_ignored(temp_dir_with_gitignore / "ignored_dir")
|
||||
assert filter_instance.is_ignored(temp_dir_with_gitignore / "ignored_dir" / "hidden.py")
|
||||
|
||||
def test_filter_allows_regular_files(self, temp_dir_with_gitignore):
|
||||
"""Test that regular files are not ignored."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
assert not filter_instance.is_ignored(temp_dir_with_gitignore / "main.py")
|
||||
assert not filter_instance.is_ignored(temp_dir_with_gitignore / "not_ignored.txt")
|
||||
assert not filter_instance.is_ignored(temp_dir_with_gitignore / "src" / "app.py")
|
||||
|
||||
def test_filter_paths_function(self, temp_dir_with_gitignore):
|
||||
"""Test the filter_paths helper function."""
|
||||
filter_instance = GitignoreFilter(temp_dir_with_gitignore)
|
||||
|
||||
paths = [
|
||||
temp_dir_with_gitignore / "main.py",
|
||||
temp_dir_with_gitignore / "test.pyc",
|
||||
temp_dir_with_gitignore / "src" / "app.py",
|
||||
temp_dir_with_gitignore / ".venv" / "lib" / "python.py",
|
||||
]
|
||||
|
||||
filtered = filter_instance.filter_paths(paths)
|
||||
|
||||
assert len(filtered) == 2
|
||||
assert temp_dir_with_gitignore / "main.py" in filtered
|
||||
assert temp_dir_with_gitignore / "src" / "app.py" in filtered
|
||||
assert temp_dir_with_gitignore / "test.pyc" not in filtered
|
||||
assert temp_dir_with_gitignore / ".venv" / "lib" / "python.py" not in filtered
|
||||
|
||||
def test_filter_gitignored_convenience(self, temp_dir_with_gitignore):
|
||||
"""Test the filter_gitignored convenience function."""
|
||||
paths = list(temp_dir_with_gitignore.rglob("*.py"))
|
||||
|
||||
filtered = filter_gitignored(paths, temp_dir_with_gitignore)
|
||||
|
||||
# Should only include main.py, src/app.py, src/utils.py
|
||||
# Should exclude .venv/lib/python.py, ignored_dir/hidden.py
|
||||
filenames = {p.name for p in filtered}
|
||||
assert "main.py" in filenames
|
||||
assert "app.py" in filenames
|
||||
assert "utils.py" in filenames
|
||||
|
||||
# Check that ignored files are not present
|
||||
ignored_paths = [str(p) for p in filtered]
|
||||
assert not any(".venv" in p for p in ignored_paths)
|
||||
assert not any("ignored_dir" in p for p in ignored_paths)
|
||||
|
||||
|
||||
class TestGlobWithGitignore:
|
||||
"""Tests for GlobFilesTool gitignore integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create a directory with ignored and non-ignored files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir)
|
||||
|
||||
# Create .gitignore
|
||||
(path / ".gitignore").write_text("""
|
||||
*.log
|
||||
build/
|
||||
""")
|
||||
|
||||
# Create files
|
||||
(path / "main.py").write_text("# Main")
|
||||
(path / "debug.log").write_text("log content")
|
||||
(path / "src").mkdir()
|
||||
(path / "src" / "app.py").write_text("# App")
|
||||
|
||||
# Create .venv (default ignored)
|
||||
(path / ".venv").mkdir()
|
||||
(path / ".venv" / "script.py").write_text("venv")
|
||||
|
||||
# Create build directory (gitignore pattern)
|
||||
(path / "build").mkdir()
|
||||
(path / "build" / "output.py").write_text("build")
|
||||
|
||||
yield path
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_glob_honors_gitignore_by_default(self, temp_dir):
|
||||
"""Test that glob filters gitignored files by default."""
|
||||
tool = GlobFilesTool()
|
||||
|
||||
result = await tool.execute(pattern="**/*.py", path=str(temp_dir))
|
||||
|
||||
assert result.success
|
||||
assert "main.py" in result.data
|
||||
assert "app.py" in result.data
|
||||
assert ".venv" not in result.data
|
||||
assert "build" not in result.data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_glob_filters_log_files(self, temp_dir):
|
||||
"""Test that custom gitignore patterns (*.log) work."""
|
||||
tool = GlobFilesTool()
|
||||
|
||||
result = await tool.execute(pattern="**/*", path=str(temp_dir))
|
||||
|
||||
assert result.success
|
||||
assert "debug.log" not in result.data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_glob_can_disable_gitignore(self, temp_dir):
|
||||
"""Test that gitignore filtering can be disabled."""
|
||||
tool = GlobFilesTool(honor_gitignore=False)
|
||||
|
||||
result = await tool.execute(pattern="**/*.py", path=str(temp_dir))
|
||||
|
||||
assert result.success
|
||||
# When disabled, should include ignored files
|
||||
assert ".venv" in result.data or "build" in result.data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_glob_override_per_call(self, temp_dir):
|
||||
"""Test per-call gitignore override."""
|
||||
tool = GlobFilesTool(honor_gitignore=True)
|
||||
|
||||
# Default behavior - filters
|
||||
result1 = await tool.execute(pattern="**/*.py", path=str(temp_dir))
|
||||
assert ".venv" not in result1.data
|
||||
|
||||
# Override to disable
|
||||
result2 = await tool.execute(
|
||||
pattern="**/*.py",
|
||||
path=str(temp_dir),
|
||||
honor_gitignore=False
|
||||
)
|
||||
assert ".venv" in result2.data or "build" in result2.data
|
||||
|
||||
|
||||
class TestGrepWithGitignore:
|
||||
"""Tests for GrepContentTool gitignore integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self):
|
||||
"""Create a directory with searchable content in ignored and non-ignored files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir)
|
||||
|
||||
# Create .gitignore
|
||||
(path / ".gitignore").write_text("""
|
||||
ignored/
|
||||
""")
|
||||
|
||||
# Create files with searchable content
|
||||
(path / "main.py").write_text("def search_target(): pass")
|
||||
(path / "src").mkdir()
|
||||
(path / "src" / "utils.py").write_text("def search_target(): # utils")
|
||||
|
||||
# Create .venv with matching content (default ignored)
|
||||
(path / ".venv").mkdir()
|
||||
(path / ".venv" / "site.py").write_text("def search_target(): # venv")
|
||||
|
||||
# Create ignored dir with matching content
|
||||
(path / "ignored").mkdir()
|
||||
(path / "ignored" / "hidden.py").write_text("def search_target(): # hidden")
|
||||
|
||||
yield path
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_grep_honors_gitignore_by_default(self, temp_dir):
|
||||
"""Test that grep filters gitignored files by default."""
|
||||
tool = GrepContentTool()
|
||||
|
||||
result = await tool.execute(pattern="search_target", path=str(temp_dir))
|
||||
|
||||
assert result.success
|
||||
assert "main.py" in result.data
|
||||
assert "utils.py" in result.data
|
||||
assert ".venv" not in result.data
|
||||
assert "ignored" not in result.data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_grep_can_disable_gitignore(self, temp_dir):
|
||||
"""Test that gitignore filtering can be disabled."""
|
||||
tool = GrepContentTool(honor_gitignore=False)
|
||||
|
||||
result = await tool.execute(pattern="search_target", path=str(temp_dir))
|
||||
|
||||
assert result.success
|
||||
# When disabled, should include ignored files
|
||||
assert ".venv" in result.data or "ignored" in result.data
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_grep_override_per_call(self, temp_dir):
|
||||
"""Test per-call gitignore override."""
|
||||
tool = GrepContentTool(honor_gitignore=True)
|
||||
|
||||
# Default behavior - filters
|
||||
result1 = await tool.execute(pattern="search_target", path=str(temp_dir))
|
||||
assert ".venv" not in result1.data
|
||||
|
||||
# Override to disable
|
||||
result2 = await tool.execute(
|
||||
pattern="search_target",
|
||||
path=str(temp_dir),
|
||||
honor_gitignore=False
|
||||
)
|
||||
assert ".venv" in result2.data or "ignored" in result2.data
|
||||
|
||||
|
||||
class TestDefaultIgnores:
|
||||
"""Tests for default ignore patterns (no .gitignore file)."""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_no_gitignore(self):
|
||||
"""Create a directory without .gitignore but with common ignored dirs."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir)
|
||||
|
||||
# Create files
|
||||
(path / "main.py").write_text("# Main")
|
||||
|
||||
# Create commonly ignored directories
|
||||
(path / ".venv").mkdir()
|
||||
(path / ".venv" / "script.py").write_text("venv")
|
||||
|
||||
(path / "__pycache__").mkdir()
|
||||
(path / "__pycache__" / "cache.pyc").write_bytes(b"cache")
|
||||
|
||||
(path / "node_modules").mkdir()
|
||||
(path / "node_modules" / "package.js").write_text("js")
|
||||
|
||||
yield path
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_glob_ignores_defaults_without_gitignore(self, temp_dir_no_gitignore):
|
||||
"""Test that default ignores work even without .gitignore."""
|
||||
tool = GlobFilesTool()
|
||||
|
||||
result = await tool.execute(pattern="**/*", path=str(temp_dir_no_gitignore))
|
||||
|
||||
assert result.success
|
||||
assert "main.py" in result.data
|
||||
assert ".venv" not in result.data
|
||||
assert "__pycache__" not in result.data
|
||||
assert "node_modules" not in result.data
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Health endpoint tests.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_root(client):
|
||||
"""Test root endpoint returns service info."""
|
||||
response = await client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert "Webber" in data["service"]
|
||||
assert data["status"] == "healthy"
|
||||
assert "version" in data
|
||||
assert data["docs"] == "/docs"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_check(client):
|
||||
"""Test health check endpoint."""
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert "Webber" in data["service"]
|
||||
assert "version" in data
|
||||
@@ -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()
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Webber Server Startup Script
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Starting Webber...${NC}"
|
||||
|
||||
# Development port (8095) - Production uses 8086 in Docker
|
||||
DEV_PORT=8095
|
||||
|
||||
# Check if dev port is already in use
|
||||
if lsof -Pi :$DEV_PORT -sTCP:LISTEN -t >/dev/null 2>&1 ; then
|
||||
echo -e "${RED}Error: Port $DEV_PORT is already in use${NC}"
|
||||
echo "Run: lsof -i :$DEV_PORT to see what's using it"
|
||||
echo "Or run: kill \$(lsof -t -i:$DEV_PORT) to stop it"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Activate virtual environment if not already activated
|
||||
if [ -z "$VIRTUAL_ENV" ]; then
|
||||
if [ -d ".venv" ]; then
|
||||
echo -e "${YELLOW}Activating virtual environment...${NC}"
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo -e "${RED}Error: Virtual environment not found${NC}"
|
||||
echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -r requirements-dev.txt"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
LOGS_DIR="logs"
|
||||
mkdir -p "$LOGS_DIR"
|
||||
|
||||
# Clear/create log file
|
||||
LOG_FILE="$LOGS_DIR/server.log"
|
||||
> "$LOG_FILE"
|
||||
echo -e "${YELLOW}Logs will be written to: ${LOG_FILE}${NC}"
|
||||
|
||||
# Start the server
|
||||
echo -e "${GREEN}Starting uvicorn server on http://localhost:$DEV_PORT${NC}"
|
||||
echo -e "${YELLOW}Production is on :8086, dev is on :$DEV_PORT${NC}"
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop the server${NC}"
|
||||
echo ""
|
||||
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port $DEV_PORT 2>&1 | tee "$LOG_FILE"
|
||||
Reference in New Issue
Block a user