Compare commits
12
Commits
v0.2.1
..
api/v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f3b241485 | ||
|
|
c839e263f9 | ||
|
|
ef69d9c945 | ||
|
|
82a816a5b5 | ||
|
|
f6256363a2 | ||
|
|
d0fa5b38a7 | ||
|
|
3b58fa4f8b | ||
|
|
f4e8552298 | ||
|
|
667e2ca8e4 | ||
|
|
34b68621c6 | ||
|
|
33b7e72b18 | ||
|
|
d046831903 |
@@ -1,20 +1,31 @@
|
|||||||
name: Build and Push
|
name: Build and Push API
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'api/v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
# Extract version from api/v0.3.0 -> v0.3.0
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
VERSION="${VERSION#api/}"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
run: |
|
run: |
|
||||||
curl -sf -X POST \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "API Release ${{ steps.version.outputs.version }}", "body": "Automated release for webber-api ${{ steps.version.outputs.version }}"}' \
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@@ -23,6 +34,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
VERSION="${VERSION#api/}"
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -33,11 +51,11 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ./webber-api
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.schweitz.internal/jpmschweitzer/webber:latest
|
${{ env.IMAGE_NAME }}:latest
|
||||||
git.schweitz.internal/jpmschweitzer/webber:${{ github.ref_name }}
|
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
- name: Trigger Watchtower update
|
- name: Trigger Watchtower update
|
||||||
if: success()
|
if: success()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build and Release CLI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'cli/v*'
|
||||||
|
|
||||||
|
# TODO: Implement CLI installer build
|
||||||
|
# This workflow will be implemented when CLI distribution is ready.
|
||||||
|
# Possible targets:
|
||||||
|
# - PyPI package
|
||||||
|
# - Standalone binary (PyInstaller)
|
||||||
|
# - Platform-specific installers
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
placeholder:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION="${{ github.ref_name }}"
|
||||||
|
VERSION="${VERSION#cli/}"
|
||||||
|
echo "CLI release triggered for version: $VERSION"
|
||||||
|
echo "TODO: Implement CLI build and distribution"
|
||||||
+11
@@ -64,3 +64,14 @@ Thumbs.db
|
|||||||
# Project specific
|
# Project specific
|
||||||
*.db
|
*.db
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
# Monorepo - subproject venvs (explicit for clarity)
|
||||||
|
webber-api/.venv/
|
||||||
|
webber-cli/.venv/
|
||||||
|
webber-sandbox/.venv/
|
||||||
|
|
||||||
|
# Sandbox marker file
|
||||||
|
webber-sandbox/.current_template
|
||||||
|
|
||||||
|
# Ruff cache
|
||||||
|
.ruff_cache/
|
||||||
|
|||||||
@@ -1,116 +1,245 @@
|
|||||||
|
# Webber Monorepo - Agent Instructions
|
||||||
# AGENTS.md
|
|
||||||
|
|
||||||
> **Start every session by reading this file.**
|
> **Start every session by reading this file.**
|
||||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
> This file contains everything you need to work with this codebase efficiently.
|
||||||
|
|
||||||
## 1. Agent Operational Protocols
|
## Quick Reference
|
||||||
|
|
||||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
| Action | Command |
|
||||||
* **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.
|
| Start API server | `cd webber-api && ./wakeup.sh` |
|
||||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
| View API logs | `tail -f webber-api/logs/server.log` |
|
||||||
|
| Run API tests | `cd webber-api && .venv/bin/python -m pytest tests/ -v` |
|
||||||
### 🛡️ Git Discipline
|
| Check CLI status | `cd webber-cli && .venv/bin/webber-cli status` |
|
||||||
* **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.
|
| Load sandbox | `./sandbox.sh load calculator-cli` |
|
||||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
| Explore sandbox | `cd webber-cli && .venv/bin/webber-cli explore "query" -d ../webber-sandbox` |
|
||||||
* **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
|
## Repository Structure
|
||||||
|
|
||||||
* **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
|
webber/
|
||||||
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup
|
├── webber-api/ # FastAPI backend server
|
||||||
|
│ ├── src/ # API source code
|
||||||
|
│ ├── tests/ # API tests (pytest)
|
||||||
|
│ ├── docs/ # Architecture docs, COVERAGE.md
|
||||||
|
│ ├── logs/ # Runtime logs (server.log)
|
||||||
|
│ ├── .venv/ # API virtual environment
|
||||||
|
│ ├── wakeup.sh # Dev server startup script
|
||||||
|
│ └── AGENTS.md # API-specific development guide
|
||||||
|
│
|
||||||
|
├── webber-cli/ # CLI client
|
||||||
|
│ ├── webber_cli/ # Python package (underscore!)
|
||||||
|
│ ├── .venv/ # CLI virtual environment
|
||||||
|
│ └── README.md # CLI usage guide
|
||||||
|
│
|
||||||
|
├── webber-sandbox/ # Active test project (contents swappable)
|
||||||
|
│ ├── src/ # Current project source
|
||||||
|
│ ├── tests/ # Current project tests
|
||||||
|
│ ├── .venv/ # Sandbox virtual environment
|
||||||
|
│ └── TASKS.md # Tasks for Webber to complete
|
||||||
|
│
|
||||||
|
├── sandbox-templates/ # Template storage
|
||||||
|
│ ├── calculator-cli/ # Simple CLI with intentional bugs
|
||||||
|
│ └── empty/ # Blank starter project
|
||||||
|
│
|
||||||
|
├── sandbox.sh # Sandbox management script
|
||||||
|
└── AGENTS.md # THIS FILE
|
||||||
|
```
|
||||||
|
|
||||||
#### ⚠️ CRITICAL: Starting the Local Server
|
---
|
||||||
|
|
||||||
**ALWAYS use `./wakeup.sh` to start the local server. NEVER use raw uvicorn commands.**
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Start the API Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd webber-api
|
||||||
./wakeup.sh
|
./wakeup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The wakeup script provides:
|
- **Port:** 8095 (dev), 8086 (production Docker)
|
||||||
- **Port conflict detection** - Warns if port 8086 is already in use
|
- **Logs:** `webber-api/logs/server.log`
|
||||||
- **Virtual environment activation** - Ensures correct Python environment
|
- **Health check:** `curl http://localhost:8095/health`
|
||||||
- **Centralized logging** - All logs written to `logs/server.log` for easy tailing
|
- **API docs:** http://localhost:8095/docs
|
||||||
- **Auto-reload** - Code changes picked up automatically (except requirements.txt changes)
|
|
||||||
- **Consistent configuration** - Same startup every time
|
To stop: `Ctrl+C` or `pkill -f "uvicorn src.main:app"`
|
||||||
|
|
||||||
|
### 2. Run Tests
|
||||||
|
|
||||||
To monitor logs in another terminal:
|
|
||||||
```bash
|
```bash
|
||||||
tail -f logs/server.log
|
# API tests (39 tests)
|
||||||
|
cd webber-api
|
||||||
|
.venv/bin/python -m pytest tests/ -v
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
.venv/bin/python -m pytest tests/ --cov=src
|
||||||
|
|
||||||
|
# Single test file
|
||||||
|
.venv/bin/python -m pytest tests/test_tools.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
To stop the server: Press `Ctrl+C`
|
### 3. Use the CLI
|
||||||
|
|
||||||
To kill a stuck server:
|
|
||||||
```bash
|
```bash
|
||||||
pkill -f "uvicorn src.main:app"
|
cd webber-cli
|
||||||
# or
|
|
||||||
kill $(lsof -t -i:8086)
|
# Check API connection
|
||||||
|
.venv/bin/webber-cli status
|
||||||
|
|
||||||
|
# Explore a directory
|
||||||
|
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
|
||||||
|
|
||||||
|
# Interactive chat mode
|
||||||
|
.venv/bin/webber-cli chat -d ../webber-sandbox
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Testing
|
**Note:** The API server must be running for CLI commands to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sandbox Management
|
||||||
|
|
||||||
|
The sandbox is a swappable test project for functional testing.
|
||||||
|
|
||||||
|
### Available Templates
|
||||||
|
|
||||||
|
| Template | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `calculator-cli` | Python CLI with intentional bugs (div-by-zero, missing tests) |
|
||||||
|
| `empty` | Blank starter project |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
**Test REST endpoints** against `http://localhost:8086`:
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8086/health
|
# List available templates
|
||||||
curl http://localhost:8086/
|
./sandbox.sh list
|
||||||
curl http://localhost:8086/docs # Swagger UI
|
|
||||||
|
# Load a template (clears sandbox, preserves .venv)
|
||||||
|
./sandbox.sh load calculator-cli
|
||||||
|
|
||||||
|
# Reset to last loaded template
|
||||||
|
./sandbox.sh reset
|
||||||
|
|
||||||
|
# Save current sandbox as new template
|
||||||
|
./sandbox.sh save my-template
|
||||||
|
|
||||||
|
# Check current status
|
||||||
|
./sandbox.sh status
|
||||||
```
|
```
|
||||||
|
|
||||||
**Running tests**: Always use the venv explicitly to avoid environment mismatches:
|
### After Loading a Template
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m pytest tests/ # All tests
|
cd webber-sandbox
|
||||||
.venv/bin/python -m pytest tests/ -v # Verbose output
|
source .venv/bin/activate # Create .venv first if missing
|
||||||
.venv/bin/python -m pytest tests/ --cov # With coverage
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Read the tasks
|
||||||
|
cat TASKS.md
|
||||||
|
|
||||||
|
# Run the project's tests
|
||||||
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. FastAPI Architecture & Best Practices
|
## Testing Webber's Capabilities
|
||||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
|
||||||
|
|
||||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
### Scenario: Find bugs in calculator-cli
|
||||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
|
||||||
|
|
||||||
**Correct Structure:**
|
```bash
|
||||||
```text
|
# 1. Load the template
|
||||||
to be determined
|
./sandbox.sh load calculator-cli
|
||||||
|
|
||||||
|
# 2. Have Webber explore it
|
||||||
|
cd webber-cli
|
||||||
|
.venv/bin/webber-cli explore "find all bugs in the code" -d ../webber-sandbox
|
||||||
|
|
||||||
|
# 3. Check TASKS.md for expected bugs
|
||||||
|
cat ../webber-sandbox/TASKS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known bugs in calculator-cli:
|
||||||
|
- Division by zero not handled (`operations.py:divide`)
|
||||||
|
- Invalid operation causes KeyError (`main.py:get_operation`)
|
||||||
|
- Power function broken for fractional exponents
|
||||||
|
- Missing tests for divide and power functions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files for Debugging
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `webber-api/logs/server.log` | API server logs |
|
||||||
|
| `webber-api/src/domains/agents/explore/prompts.py` | Explore agent system prompts |
|
||||||
|
| `webber-api/src/domains/agents/explore/agent.py` | Explore agent implementation |
|
||||||
|
| `webber-api/src/ollama/provider.py` | Ollama integration (sanitizes content:null) |
|
||||||
|
| `webber-api/docs/COVERAGE.md` | Feature coverage and known issues |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning & Releases
|
||||||
|
|
||||||
|
Uses prefixed tags:
|
||||||
|
- `api/v0.3.0` → Triggers API Docker build
|
||||||
|
- `cli/v0.1.0` → Triggers CLI build (future)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API release
|
||||||
|
cd webber-api
|
||||||
|
# Update version in pyproject.toml
|
||||||
|
git add -A && git commit -m "chore: release api v0.3.0"
|
||||||
|
git tag api/v0.3.0
|
||||||
|
git push origin main --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### API server won't start
|
||||||
|
```bash
|
||||||
|
# Check if port is in use
|
||||||
|
lsof -i :8095
|
||||||
|
|
||||||
|
# Kill stuck process
|
||||||
|
pkill -f "uvicorn src.main:app"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI can't connect
|
||||||
|
```bash
|
||||||
|
# Check API is running
|
||||||
|
curl http://localhost:8095/health
|
||||||
|
|
||||||
|
# Check CLI config
|
||||||
|
echo $WEBBER_API_URL # Should be http://localhost:8095
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ollama errors
|
||||||
|
```bash
|
||||||
|
# Check Ollama is running
|
||||||
|
curl http://192.168.86.149:11434/api/tags
|
||||||
|
|
||||||
|
# Check model is available
|
||||||
|
curl http://192.168.86.149:11434/api/tags | grep mistral-nemo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests failing
|
||||||
|
```bash
|
||||||
|
# Run with verbose output
|
||||||
|
cd webber-api
|
||||||
|
.venv/bin/python -m pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
|
||||||
|
2. **No conversation memory** - CLI chat mode doesn't persist between sessions
|
||||||
|
3. **No streaming** - Responses appear all at once
|
||||||
|
|
||||||
|
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
|
||||||
|
|||||||
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Integration test infrastructure with pytest markers (integration, e2e, slow)
|
||||||
|
- 10 LLM integration tests (requires Ollama)
|
||||||
|
- 12 E2E API tests (requires running server)
|
||||||
|
- Command line options: `--run-integration`, `--run-e2e`, `--ollama-url`, `--api-url`
|
||||||
|
- Sample project fixtures for testing
|
||||||
|
- 14 security tests (path traversal, command injection, input validation)
|
||||||
|
- Helper functions: `assert_contains_any`, `assert_contains_all`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~65% complete
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Explore agent with PydanticAI tool calling and Mistral Nemo
|
||||||
|
- Coding tools: `edit_file`, `write_file`, `bash` (full)
|
||||||
|
- Web search tool using SearXNG integration
|
||||||
|
- Streaming responses via SSE for API and CLI
|
||||||
|
- CLI commands: `explore`, `chat`, `status`
|
||||||
|
- Sanitized Ollama provider (fixes `content: null` issue)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Reorganized into monorepo structure (webber-api/, webber-cli/, webber-sandbox/)
|
||||||
|
- Added ruff linter and fixed mypy errors
|
||||||
|
|
||||||
|
## [0.2.3] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Docker healthcheck for container health monitoring
|
||||||
|
|
||||||
|
## [0.2.2] - 2026-01-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Config parsing for empty environment variables (allowed_paths, cors_*)
|
||||||
|
- Use `env_parse_none_str=""` to treat empty strings as None
|
||||||
|
|
||||||
## [0.2.1] - 2026-01-09
|
## [0.2.1] - 2026-01-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Webber - Multi-Agent AI Development System
|
||||||
|
|
||||||
|
A Claude Code-inspired development assistant powered by local LLMs via Ollama.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Explore Agent** - Search, read, and understand codebases
|
||||||
|
- **8 Tools** - File read/write, glob, grep, bash, web search
|
||||||
|
- **Streaming** - Real-time response display
|
||||||
|
- **Self-hosted** - Runs on your own hardware with Ollama
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
This is a monorepo containing three subprojects:
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `webber-api/` | FastAPI backend server with agent orchestration |
|
||||||
|
| `webber-cli/` | Command-line client for interacting with the API |
|
||||||
|
| `webber-sandbox/` | Test project for functional testing |
|
||||||
|
|
||||||
|
### Additional Directories
|
||||||
|
|
||||||
|
| Directory | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `sandbox-templates/` | Reusable project templates for the sandbox |
|
||||||
|
| `.gitea/workflows/` | CI/CD workflows for releases |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Start the API Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webber-api
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
./wakeup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set Up the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webber-cli
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
webber-cli status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Explore with Webber
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-shot exploration
|
||||||
|
webber-cli explore "find all bugs in the code" -d /path/to/project
|
||||||
|
|
||||||
|
# Interactive chat
|
||||||
|
webber-cli chat -d /path/to/project
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `read_file` | Read file contents with line numbers |
|
||||||
|
| `glob_files` | Find files by pattern |
|
||||||
|
| `grep_content` | Search file contents with regex |
|
||||||
|
| `bash_readonly` | Safe bash commands (ls, git status, etc.) |
|
||||||
|
| `edit_file` | Find-and-replace editing |
|
||||||
|
| `write_file` | Create/overwrite files |
|
||||||
|
| `bash` | Full bash with safety controls |
|
||||||
|
| `web_search` | Search web via SearXNG |
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
This project uses prefixed tags for independent release cycles:
|
||||||
|
|
||||||
|
- `api/v0.3.0` - Triggers API Docker build and deployment
|
||||||
|
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- Ollama running with `mistral-nemo:latest` model
|
||||||
|
- Docker (for production deployment)
|
||||||
|
- SearXNG (optional, for web search)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `webber-api/AGENTS.md` - API development guidelines
|
||||||
|
- `webber-api/docs/COVERAGE.md` - Feature coverage and roadmap
|
||||||
|
- `webber-api/docs/architecture.md` - System architecture
|
||||||
|
- `webber-cli/README.md` - CLI usage guide
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -1,628 +0,0 @@
|
|||||||
# Webber FastAPI Boilerplate Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Set up FastAPI boilerplate for "Webber" - a multi-agent AI development system (similar to Claude Code, but local with different models). Follows core-api patterns with defensive coding practices.
|
|
||||||
|
|
||||||
**Key Decision: PydanticAI Framework**
|
|
||||||
After research, [PydanticAI](https://ai.pydantic.dev/) is the recommended agent coordination framework:
|
|
||||||
- Model-agnostic: supports Ollama, OpenAI, Anthropic, and 20+ providers
|
|
||||||
- Type-safe with Pydantic validation (same ecosystem as FastAPI)
|
|
||||||
- Built-in tool/function calling with automatic schema generation
|
|
||||||
- Multi-agent support for complex workflows
|
|
||||||
- Maintained by Pydantic team (285M+ monthly downloads)
|
|
||||||
|
|
||||||
**Port: 8086** (next available slot after Headscale 8085 per CONTAINERS.md)
|
|
||||||
|
|
||||||
**Default Models (always hot in VRAM on tower-of-joy):**
|
|
||||||
- Agent reasoning: `mistral-nemo-large:latest`
|
|
||||||
- Embeddings: `nomic-embed-text:latest`
|
|
||||||
|
|
||||||
**Target Clients:**
|
|
||||||
- **Tatlock Butler**: External advisor integration for coding/software guidance
|
|
||||||
- **CLI Interface**: TBD - command-line interface for local development
|
|
||||||
|
|
||||||
**Multi-tenancy:** API key authentication integrated with tatlock-ui/core-api user management
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
webber/
|
|
||||||
├── AGENTS.md # Expanded with defensive LLM guidelines
|
|
||||||
├── README.md # Project overview
|
|
||||||
├── CHANGELOG.md # Version history
|
|
||||||
├── pyproject.toml # Package metadata
|
|
||||||
├── requirements.txt # Production dependencies only (~= pinned)
|
|
||||||
├── requirements-dev.txt # Dev/test dependencies (pytest, pip-audit, etc.)
|
|
||||||
├── .env.example # Environment template
|
|
||||||
├── wakeup.sh # Dev startup (update port to 8086)
|
|
||||||
│
|
|
||||||
├── src/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # FastAPI app, lifespan, user provider init
|
|
||||||
│ │ # NO routes here - delegates to domain routers
|
|
||||||
│ │
|
|
||||||
│ ├── 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 # API key validation, multi-tenant support
|
|
||||||
│ │ └── context.py # UserProvider singleton, request context
|
|
||||||
│ │
|
|
||||||
│ └── domains/ # Feature domains (each with router.py)
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── router.py # Root router - includes all domain routers
|
|
||||||
│ │
|
|
||||||
│ ├── health/ # Health endpoints
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── router.py # Health routes
|
|
||||||
│ │ └── controller.py # Health logic
|
|
||||||
│ │
|
|
||||||
│ ├── auth/ # Authentication domain
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── router.py # Auth routes (API key mgmt)
|
|
||||||
│ │ ├── controller.py
|
|
||||||
│ │ └── schemas.py
|
|
||||||
│ │
|
|
||||||
│ │── agents/ # Agent domain container
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── router.py # Agent routes (lists agents, runs them)
|
|
||||||
│ │ ├── controller.py # Agent orchestration logic
|
|
||||||
│ │ ├── schemas.py
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── explore/ # Explore agent (codebase navigation)
|
|
||||||
│ │ │ ├── __init__.py
|
|
||||||
│ │ │ ├── agent.py # PydanticAI agent definition
|
|
||||||
│ │ │ └── prompts.py # System prompts
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── plan/ # Plan agent (implementation design)
|
|
||||||
│ │ │ ├── __init__.py
|
|
||||||
│ │ │ ├── agent.py
|
|
||||||
│ │ │ └── prompts.py
|
|
||||||
│ │ │
|
|
||||||
│ │ └── task/ # Task agent (execution)
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── agent.py
|
|
||||||
│ │ └── prompts.py
|
|
||||||
│ │
|
|
||||||
│ └── tools/ # Tool domain container
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── router.py # Tool routes (list tools, execute)
|
|
||||||
│ ├── controller.py # Tool orchestration
|
|
||||||
│ ├── schemas.py
|
|
||||||
│ │
|
|
||||||
│ ├── file/ # File operation tools
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── read.py
|
|
||||||
│ │ ├── write.py
|
|
||||||
│ │ └── glob.py
|
|
||||||
│ │
|
|
||||||
│ ├── shell/ # Shell execution tools
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── bash.py
|
|
||||||
│ │
|
|
||||||
│ └── search/ # Search tools
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── grep.py
|
|
||||||
│ └── web.py
|
|
||||||
│
|
|
||||||
├── tests/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── conftest.py
|
|
||||||
│ └── test_health.py
|
|
||||||
│
|
|
||||||
└── docs/
|
|
||||||
└── architecture.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Architectural Decisions
|
|
||||||
|
|
||||||
1. **Clean main.py**: Only app creation, lifespan, and UserProvider init. All routes in domain routers.
|
|
||||||
2. **Domain routers**: Each domain has `router.py` that defines routes. Root `domains/router.py` composes them.
|
|
||||||
3. **Separate agent domains**: Each agent type (explore, plan, task) in its own subdir under `agents/`.
|
|
||||||
4. **Separate tool domains**: Each tool category (file, shell, search) in its own subdir under `tools/`.
|
|
||||||
5. **UserProvider singleton**: Set once in main.py lifespan, accessible everywhere via `shared/context.py`.
|
|
||||||
6. **Multi-tenant auth**: API key validation in `shared/auth.py`, integrates with tatlock-ui/core-api.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Key Files to Create
|
|
||||||
|
|
||||||
### Phase 1: Foundation (fully implemented)
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/shared/base.py` | BaseController, BaseSchema |
|
|
||||||
| `src/shared/config.py` | Settings via Pydantic BaseSettings |
|
|
||||||
| `src/shared/logging.py` | Logger decorator + centralized setup |
|
|
||||||
| `src/shared/exceptions.py` | Custom exception hierarchy |
|
|
||||||
| `src/shared/auth.py` | API key validation, tatlock integration stub |
|
|
||||||
| `src/shared/context.py` | UserProvider singleton pattern |
|
|
||||||
| `src/main.py` | FastAPI app, lifespan, UserProvider init (no routes!) |
|
|
||||||
| `src/domains/router.py` | Root router composing all domain routers |
|
|
||||||
| `src/domains/health/router.py` | Health routes |
|
|
||||||
| `src/domains/health/controller.py` | Health logic |
|
|
||||||
| `pyproject.toml` | Package metadata, pytest config |
|
|
||||||
| `requirements.txt` | Production deps (~= pinned) |
|
|
||||||
| `requirements-dev.txt` | Dev/test deps (pytest, pip-audit) |
|
|
||||||
| `.env.example` | Environment variable template |
|
|
||||||
| `tests/conftest.py` | Pytest fixtures |
|
|
||||||
| `tests/test_health.py` | Basic endpoint tests |
|
|
||||||
|
|
||||||
### Phase 2: Placeholders (structure + README docs)
|
|
||||||
| Directory | Purpose |
|
|
||||||
|-----------|---------|
|
|
||||||
| `src/domains/auth/` | API key management (stub) |
|
|
||||||
| `src/domains/agents/` | Agent container with explore/plan/task subdirs |
|
|
||||||
| `src/domains/tools/` | Tool container with file/shell/search subdirs |
|
|
||||||
| `docs/architecture.md` | System design documentation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Dependency Management
|
|
||||||
|
|
||||||
### requirements.txt (Production - baked into Docker)
|
|
||||||
```
|
|
||||||
# Webber Production Dependencies
|
|
||||||
# Minor version pinning (~=) for security patches
|
|
||||||
# CVE check date: 2026-01-09
|
|
||||||
# CVE check sources: PyPI, GitHub Advisories, Snyk, NVD
|
|
||||||
|
|
||||||
# Core FastAPI
|
|
||||||
fastapi~=0.115.0
|
|
||||||
starlette~=0.45.0
|
|
||||||
uvicorn[standard]~=0.34.0
|
|
||||||
pydantic~=2.11.0
|
|
||||||
pydantic-settings~=2.7.0
|
|
||||||
|
|
||||||
# Agent Framework
|
|
||||||
pydantic-ai~=0.0.39 # Multi-agent LLM orchestration
|
|
||||||
|
|
||||||
# HTTP
|
|
||||||
httpx~=0.28.0
|
|
||||||
aiofiles~=24.1.0
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
python-multipart~=0.0.18
|
|
||||||
python-dotenv~=1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### requirements-dev.txt (Dev/Test only - NOT in Docker)
|
|
||||||
```
|
|
||||||
# Webber Development Dependencies
|
|
||||||
# Install with: pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
-r requirements.txt # Include production deps
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
pytest~=8.3.0
|
|
||||||
pytest-asyncio~=0.24.0
|
|
||||||
pytest-cov~=6.0.0
|
|
||||||
|
|
||||||
# Security auditing
|
|
||||||
pip-audit~=2.7.0 # Run before releases: pip-audit
|
|
||||||
|
|
||||||
# Type checking
|
|
||||||
mypy~=1.13.0
|
|
||||||
|
|
||||||
# Code formatting (optional)
|
|
||||||
# ruff~=0.8.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. AGENTS.md Additions
|
|
||||||
|
|
||||||
Add these new sections:
|
|
||||||
|
|
||||||
### Section 3: Defensive LLM Coding Practices
|
|
||||||
- Input validation requirements
|
|
||||||
- Output parsing guidelines (expect malformed responses)
|
|
||||||
- Timeout and retry policies
|
|
||||||
- Security: no secrets in prompts, sandbox execution
|
|
||||||
|
|
||||||
### Section 4: Pattern Reuse Requirements
|
|
||||||
- Search existing code before writing new
|
|
||||||
- Check `src/shared/` for base classes
|
|
||||||
- Follow domain structure template
|
|
||||||
- Code review checklist
|
|
||||||
|
|
||||||
### Section 5: CVE Check Process
|
|
||||||
- Check PyPI, GitHub Advisories, Snyk, NVD before adding deps
|
|
||||||
- Document CVE decisions in requirements.txt
|
|
||||||
- Run `pip-audit` before releases
|
|
||||||
|
|
||||||
### Section 6: Mandatory Documentation
|
|
||||||
- Required reading before work: AGENTS.md, docs/architecture.md, src/shared/base.py
|
|
||||||
- Changelog and docstring requirements
|
|
||||||
|
|
||||||
### Section 7: Project Structure Reference
|
|
||||||
- Directory tree with explanations
|
|
||||||
- Domain structure template
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Configuration (Settings)
|
|
||||||
|
|
||||||
Environment variables for:
|
|
||||||
- **App**: DEBUG, LOG_LEVEL
|
|
||||||
- **Server**: HOST, PORT (default **8086** per CONTAINERS.md allocation)
|
|
||||||
- **CORS**: origins, methods, headers
|
|
||||||
- **LLM Models** (hot in VRAM on tower-of-joy):
|
|
||||||
- OLLAMA_URL (default: http://192.168.86.149:11434)
|
|
||||||
- OLLAMA_AGENT_MODEL (default: mistral-nemo-large:latest)
|
|
||||||
- OLLAMA_EMBED_MODEL (default: nomic-embed-text:latest)
|
|
||||||
- **Auth**:
|
|
||||||
- TATLOCK_API_URL (default: http://192.168.86.149:8000)
|
|
||||||
- Internal API key for tatlock user validation
|
|
||||||
- **Tools**: TOOL_TIMEOUT_SECONDS, SANDBOX_ENABLED, ALLOWED_PATHS
|
|
||||||
- **Sessions**: SESSION_TTL_HOURS, MAX_CONTEXT_TOKENS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Core Patterns
|
|
||||||
|
|
||||||
### Logger Decorator with Temporal Benchmarking (shared/logging.py)
|
|
||||||
```python
|
|
||||||
import functools
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
from typing import Callable, Optional
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
# Trace context for nested timing
|
|
||||||
@dataclass
|
|
||||||
class TraceSpan:
|
|
||||||
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:
|
|
||||||
if self.end_time is None:
|
|
||||||
return (time.perf_counter() - self.start_time) * 1000
|
|
||||||
return (self.end_time - self.start_time) * 1000
|
|
||||||
|
|
||||||
# Context variable for trace propagation
|
|
||||||
_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 correlation."""
|
|
||||||
return _trace_id.get()
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Build log context
|
|
||||||
ctx = {
|
|
||||||
"trace_id": span.trace_id,
|
|
||||||
"span_id": span.span_id,
|
|
||||||
"duration_ms": round(duration, 2),
|
|
||||||
"func": func_name,
|
|
||||||
}
|
|
||||||
if span.parent_id:
|
|
||||||
ctx["parent_id"] = span.parent_id
|
|
||||||
|
|
||||||
if error:
|
|
||||||
logger.error(
|
|
||||||
f"[{span.trace_id[:8]}] {func_name} FAILED after {duration:.2f}ms: {error}",
|
|
||||||
extra=ctx,
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
elif duration >= warn_threshold_ms:
|
|
||||||
logger.warning(
|
|
||||||
f"[{span.trace_id[:8]}] {func_name} SLOW: {duration:.2f}ms (threshold: {warn_threshold_ms}ms)",
|
|
||||||
extra=ctx
|
|
||||||
)
|
|
||||||
elif duration >= slow_threshold_ms:
|
|
||||||
logger.info(
|
|
||||||
f"[{span.trace_id[:8]}] {func_name} completed in {duration:.2f}ms",
|
|
||||||
extra=ctx
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
f"[{span.trace_id[:8]}] {func_name} completed in {duration:.2f}ms",
|
|
||||||
extra=ctx
|
|
||||||
)
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience for manual span creation (context manager)
|
|
||||||
class trace_span:
|
|
||||||
"""
|
|
||||||
Context manager for manual span creation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
with trace_span("database_query"):
|
|
||||||
result = await db.execute(query)
|
|
||||||
|
|
||||||
async with trace_span("llm_call"):
|
|
||||||
response = await agent.run(prompt)
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
if exc_val:
|
|
||||||
self.logger.error(f"[{self.span.trace_id[:8]}] {self.name} FAILED: {duration:.2f}ms")
|
|
||||||
else:
|
|
||||||
self.logger.debug(f"[{self.span.trace_id[:8]}] {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.agents.controller.run_agent
|
|
||||||
DEBUG [a1b2c3d4] -> src.domains.llm.service.call_ollama
|
|
||||||
DEBUG [a1b2c3d4] src.domains.llm.service.call_ollama: 45.23ms
|
|
||||||
INFO [a1b2c3d4] src.domains.agents.controller.run_agent completed in 156.78ms
|
|
||||||
WARN [a1b2c3d4] src.domains.tools.file.read.read_file SLOW: 523.45ms (threshold: 500ms)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- **Trace IDs**: Correlate logs across nested calls
|
|
||||||
- **Parent/child spans**: Track call hierarchy
|
|
||||||
- **Configurable thresholds**: `slow_threshold_ms` (INFO), `warn_threshold_ms` (WARNING)
|
|
||||||
- **Context manager**: `trace_span()` for manual instrumentation of code blocks
|
|
||||||
- **Zero overhead path**: Fast path for sub-threshold calls (DEBUG only)
|
|
||||||
|
|
||||||
### UserProvider Singleton (shared/context.py)
|
|
||||||
```python
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
from contextvars import ContextVar
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class User:
|
|
||||||
id: str
|
|
||||||
email: str
|
|
||||||
api_key: str
|
|
||||||
tenant_id: Optional[str] = None
|
|
||||||
|
|
||||||
# Context variable for request-scoped user
|
|
||||||
_current_user: ContextVar[Optional[User]] = ContextVar('current_user', default=None)
|
|
||||||
|
|
||||||
class UserProvider:
|
|
||||||
"""Singleton for user context management."""
|
|
||||||
_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)
|
|
||||||
|
|
||||||
# Global singleton
|
|
||||||
user_provider = UserProvider()
|
|
||||||
```
|
|
||||||
|
|
||||||
### BaseController (from core-api)
|
|
||||||
```python
|
|
||||||
class BaseController(ABC):
|
|
||||||
def __init__(self, prefix: str, tags: list[str]):
|
|
||||||
self.prefix = prefix
|
|
||||||
self.tags = tags
|
|
||||||
self._router = None
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def create_router(self) -> APIRouter: pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def router(self) -> APIRouter:
|
|
||||||
if self._router is None:
|
|
||||||
self._router = self.create_router()
|
|
||||||
return self._router
|
|
||||||
```
|
|
||||||
|
|
||||||
### PydanticAI Agent Pattern (placeholder for future)
|
|
||||||
```python
|
|
||||||
from pydantic_ai import Agent
|
|
||||||
from pydantic_ai.models.ollama import OllamaModel
|
|
||||||
|
|
||||||
# Use the hot model from VRAM
|
|
||||||
agent = Agent(
|
|
||||||
OllamaModel('mistral-nemo-large:latest'),
|
|
||||||
system_prompt='You are a helpful assistant.',
|
|
||||||
)
|
|
||||||
|
|
||||||
@agent.tool
|
|
||||||
async def search_files(ctx, pattern: str) -> str:
|
|
||||||
"""Search for files matching pattern."""
|
|
||||||
pass # Implementation in tools/search/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Implementation Order
|
|
||||||
|
|
||||||
1. **Create directory structure** (`src/`, `src/shared/`, `src/domains/`)
|
|
||||||
2. **Implement shared modules** (base.py, config.py, logging.py, exceptions.py)
|
|
||||||
3. **Create main.py** with FastAPI app and lifespan
|
|
||||||
4. **Add health domain** as working example
|
|
||||||
5. **Set up tests** (conftest.py, test_health.py)
|
|
||||||
6. **Create placeholder domains** (llm, agents, tools - structure only)
|
|
||||||
7. **Update AGENTS.md** with new sections
|
|
||||||
8. **Create supporting files** (pyproject.toml, requirements.txt, .env.example)
|
|
||||||
9. **Add docs/architecture.md**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Verification
|
|
||||||
|
|
||||||
After implementation:
|
|
||||||
1. `./wakeup.sh` starts server without errors
|
|
||||||
2. `curl http://localhost:8086/health` returns healthy
|
|
||||||
3. `http://localhost:8086/docs` shows API documentation
|
|
||||||
4. `.venv/bin/python -m pytest tests/ -v` passes
|
|
||||||
5. Code follows patterns in AGENTS.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Critical Reference Files
|
|
||||||
|
|
||||||
- `/mnt/media/Projects/core-api/src/shared/base.py` - BaseController pattern
|
|
||||||
- `/mnt/media/Projects/core-api/src/shared/config.py` - Settings pattern
|
|
||||||
- `/mnt/media/Projects/core-api/src/domains/health/controller.py` - Controller example
|
|
||||||
- https://ai.pydantic.dev/ - PydanticAI documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**What will be created:**
|
|
||||||
- Complete FastAPI project structure following core-api patterns
|
|
||||||
- Working health endpoint at `http://localhost:8086/health`
|
|
||||||
- **Clean main.py** - no routes, just app init and UserProvider setup
|
|
||||||
- **Domain routers** - each domain has router.py, composed by root router
|
|
||||||
- **Logger decorator** - centralized logging via `@logged` decorator
|
|
||||||
- **UserProvider singleton** - request-scoped user context, no parameter passing
|
|
||||||
- **Multi-tenant auth stub** - API key validation ready for tatlock integration
|
|
||||||
- Separate **requirements.txt** (prod) and **requirements-dev.txt** (dev/test)
|
|
||||||
- Placeholder domains with agent/tool subdirectories (explore, plan, task / file, shell, search)
|
|
||||||
- Comprehensive AGENTS.md with defensive LLM coding practices, CVE checks, pattern reuse
|
|
||||||
- Test infrastructure with pytest
|
|
||||||
- docs/architecture.md explaining the system design
|
|
||||||
|
|
||||||
**What will NOT be created (deferred):**
|
|
||||||
- Database layer (add when needed)
|
|
||||||
- Full agent/tool implementations (PydanticAI patterns documented for future work)
|
|
||||||
- Docker/deployment configuration (can add later)
|
|
||||||
- CLI interface (TBD)
|
|
||||||
|
|
||||||
**Key decisions:**
|
|
||||||
- Port: **8086**
|
|
||||||
- Agent framework: **PydanticAI**
|
|
||||||
- Default model: **mistral-nemo-large:latest** (hot in VRAM)
|
|
||||||
- Embeddings: **nomic-embed-text:latest** (hot in VRAM)
|
|
||||||
- No database initially
|
|
||||||
- Separate prod/dev requirements
|
|
||||||
- UserProvider singleton pattern for multi-tenancy
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "webber"
|
|
||||||
version = "0.2.1"
|
|
||||||
description = "Mrs. Webber - Multi-Agent AI Development System"
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Project
|
||||||
|
.current_template
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Calculator CLI - Tasks for Webber
|
||||||
|
|
||||||
|
A simple calculator with intentional bugs and missing features for testing Webber's capabilities.
|
||||||
|
|
||||||
|
## Bugs to Fix
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
- [ ] **Division by zero** - `operations.py:divide()` crashes when dividing by zero instead of returning an error
|
||||||
|
- [ ] **Invalid operation name** - `main.py:get_operation()` raises KeyError for unknown operations instead of helpful error message
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- [ ] **Power function broken** - `operations.py:power()` doesn't handle negative exponents or fractional exponents correctly
|
||||||
|
- [ ] **No input validation** - `main.py` doesn't validate that command-line arguments are valid numbers
|
||||||
|
|
||||||
|
## Missing Tests
|
||||||
|
|
||||||
|
- [ ] Add `TestDivide` class with tests for:
|
||||||
|
- Normal division
|
||||||
|
- Division by zero (should test error handling once bug is fixed)
|
||||||
|
- Division with negative numbers
|
||||||
|
|
||||||
|
- [ ] Add `TestPower` class with tests for:
|
||||||
|
- Positive integer exponents
|
||||||
|
- Zero exponent (should return 1)
|
||||||
|
- Negative exponents
|
||||||
|
|
||||||
|
- [ ] Complete existing test classes:
|
||||||
|
- `test_add_zero`
|
||||||
|
- `test_add_floats`
|
||||||
|
- `test_subtract_negative`
|
||||||
|
- `test_multiply_by_zero`
|
||||||
|
|
||||||
|
## Features to Add
|
||||||
|
|
||||||
|
- [ ] **Expose power operation** - Add 'pow' to the operations dictionary in `main.py`
|
||||||
|
- [ ] **Add modulo operation** - Implement `modulo(a, b)` in operations.py
|
||||||
|
- [ ] **Add --verbose flag** - Show step-by-step calculation
|
||||||
|
- [ ] **Add history command** - Track and display recent calculations
|
||||||
|
- [ ] **Add REPL mode** - Interactive calculator loop
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- [ ] Add type hints to all functions
|
||||||
|
- [ ] Add docstrings following Google style
|
||||||
|
- [ ] Fix any linting errors (run `ruff check src/`)
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run calculator
|
||||||
|
python -m calculator.main 10 5 add
|
||||||
|
python -m calculator.main 10 5 div
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# See failing tests (division by zero)
|
||||||
|
python -m calculator.main 10 0 div
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[project]
|
||||||
|
name = "calculator"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A simple calculator CLI with some bugs"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
addopts = "-v"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Calculator CLI dependencies
|
||||||
|
pytest>=8.0.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Calculator CLI - A simple calculator with some bugs for testing."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Calculator CLI - A simple command-line calculator.
|
||||||
|
|
||||||
|
NOTE: This file contains intentional bugs for testing purposes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m calculator.main 10 5 add
|
||||||
|
python -m calculator.main 10 5 sub
|
||||||
|
python -m calculator.main 10 5 mul
|
||||||
|
python -m calculator.main 10 5 div
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from calculator.operations import add, subtract, multiply, divide
|
||||||
|
|
||||||
|
|
||||||
|
def get_operation(op_name: str):
|
||||||
|
"""
|
||||||
|
Get the operation function by name.
|
||||||
|
|
||||||
|
BUG: No validation - invalid operation names cause KeyError!
|
||||||
|
"""
|
||||||
|
operations = {
|
||||||
|
"add": add,
|
||||||
|
"sub": subtract,
|
||||||
|
"mul": multiply,
|
||||||
|
"div": divide,
|
||||||
|
# BUG: 'power' is implemented in operations.py but not exposed here
|
||||||
|
}
|
||||||
|
# BUG: Should handle KeyError gracefully
|
||||||
|
return operations[op_name]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
if len(sys.argv) != 4:
|
||||||
|
print("Usage: python -m calculator.main <a> <b> <operation>")
|
||||||
|
print("Operations: add, sub, mul, div")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# BUG: No validation that a and b are valid numbers
|
||||||
|
a = float(sys.argv[1])
|
||||||
|
b = float(sys.argv[2])
|
||||||
|
op_name = sys.argv[3]
|
||||||
|
|
||||||
|
# BUG: This will crash with KeyError for invalid operation
|
||||||
|
operation = get_operation(op_name)
|
||||||
|
result = operation(a, b)
|
||||||
|
|
||||||
|
print(f"Result: {result}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Math operations for the calculator.
|
||||||
|
|
||||||
|
NOTE: This file contains intentional bugs for testing purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def add(a: float, b: float) -> float:
|
||||||
|
"""Add two numbers."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
|
||||||
|
def subtract(a: float, b: float) -> float:
|
||||||
|
"""Subtract b from a."""
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
|
||||||
|
def multiply(a: float, b: float) -> float:
|
||||||
|
"""Multiply two numbers."""
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
|
||||||
|
def divide(a: float, b: float) -> float:
|
||||||
|
"""
|
||||||
|
Divide a by b.
|
||||||
|
|
||||||
|
BUG: Does not handle division by zero!
|
||||||
|
"""
|
||||||
|
# BUG: No check for b == 0
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
|
||||||
|
def power(a: float, b: float) -> float:
|
||||||
|
"""
|
||||||
|
Raise a to the power of b.
|
||||||
|
|
||||||
|
BUG: Negative exponents not handled correctly for some cases.
|
||||||
|
"""
|
||||||
|
# BUG: This naive implementation has issues with negative bases and fractional exponents
|
||||||
|
result = 1
|
||||||
|
for _ in range(int(b)):
|
||||||
|
result *= a
|
||||||
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Calculator tests."""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Tests for calculator operations.
|
||||||
|
|
||||||
|
NOTE: Test coverage is intentionally incomplete for testing purposes.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from calculator.operations import add, subtract, multiply
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdd:
|
||||||
|
"""Tests for add operation."""
|
||||||
|
|
||||||
|
def test_add_positive_numbers(self):
|
||||||
|
assert add(2, 3) == 5
|
||||||
|
|
||||||
|
def test_add_negative_numbers(self):
|
||||||
|
assert add(-2, -3) == -5
|
||||||
|
|
||||||
|
# MISSING: test_add_zero, test_add_floats
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubtract:
|
||||||
|
"""Tests for subtract operation."""
|
||||||
|
|
||||||
|
def test_subtract_positive(self):
|
||||||
|
assert subtract(5, 3) == 2
|
||||||
|
|
||||||
|
# MISSING: test_subtract_negative, test_subtract_resulting_negative
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiply:
|
||||||
|
"""Tests for multiply operation."""
|
||||||
|
|
||||||
|
def test_multiply_positive(self):
|
||||||
|
assert multiply(3, 4) == 12
|
||||||
|
|
||||||
|
# MISSING: test_multiply_by_zero, test_multiply_negative
|
||||||
|
|
||||||
|
|
||||||
|
# MISSING: TestDivide class entirely!
|
||||||
|
# - test_divide_positive
|
||||||
|
# - test_divide_by_zero (should test error handling)
|
||||||
|
# - test_divide_negative
|
||||||
|
|
||||||
|
# MISSING: TestPower class entirely!
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Project
|
||||||
|
.current_template
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# My Project - Tasks
|
||||||
|
|
||||||
|
A blank starter template. Define your own tasks here.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] Define your project goals
|
||||||
|
- [ ] Add source files to `src/myproject/`
|
||||||
|
- [ ] Add tests to `tests/`
|
||||||
|
- [ ] Update `requirements.txt` with dependencies
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[project]
|
||||||
|
name = "myproject"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A blank starter project"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=75.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
addopts = "-v"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Add your dependencies here
|
||||||
|
pytest>=8.0.0
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""My Project - A blank starter template."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for myproject."""
|
||||||
Executable
+193
@@ -0,0 +1,193 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sandbox management script for Webber testing
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./sandbox.sh list - List available templates
|
||||||
|
# ./sandbox.sh load <template> - Load a template into sandbox
|
||||||
|
# ./sandbox.sh reset - Reset sandbox to last loaded template
|
||||||
|
# ./sandbox.sh save <name> - Save current sandbox as new template
|
||||||
|
# ./sandbox.sh status - Show current sandbox status
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SANDBOX_DIR="$SCRIPT_DIR/webber-sandbox"
|
||||||
|
TEMPLATES_DIR="$SCRIPT_DIR/sandbox-templates"
|
||||||
|
MARKER_FILE="$SANDBOX_DIR/.current_template"
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Webber Sandbox Manager"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: ./sandbox.sh <command> [template]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " list List available templates"
|
||||||
|
echo " load <template> Load a template into sandbox (preserves .venv)"
|
||||||
|
echo " reset Reset sandbox to last loaded template"
|
||||||
|
echo " save <name> Save current sandbox as new template"
|
||||||
|
echo " status Show current sandbox status"
|
||||||
|
echo ""
|
||||||
|
echo "Available templates:"
|
||||||
|
ls -1 "$TEMPLATES_DIR" 2>/dev/null || echo " (none)"
|
||||||
|
}
|
||||||
|
|
||||||
|
list_templates() {
|
||||||
|
echo "Available templates:"
|
||||||
|
echo ""
|
||||||
|
for dir in "$TEMPLATES_DIR"/*/; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
name=$(basename "$dir")
|
||||||
|
desc=""
|
||||||
|
if [ -f "$dir/TASKS.md" ]; then
|
||||||
|
desc=$(head -1 "$dir/TASKS.md" | sed 's/^#\s*//')
|
||||||
|
fi
|
||||||
|
printf " %-20s %s\n" "$name" "$desc"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
load_template() {
|
||||||
|
local template="$1"
|
||||||
|
|
||||||
|
if [ -z "$template" ]; then
|
||||||
|
echo -e "${RED}Error: Template name required${NC}"
|
||||||
|
echo "Usage: ./sandbox.sh load <template>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$TEMPLATES_DIR/$template" ]; then
|
||||||
|
echo -e "${RED}Error: Template '$template' not found${NC}"
|
||||||
|
echo "Available templates:"
|
||||||
|
ls -1 "$TEMPLATES_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Loading template: $template${NC}"
|
||||||
|
|
||||||
|
# Create sandbox dir if needed
|
||||||
|
mkdir -p "$SANDBOX_DIR"
|
||||||
|
|
||||||
|
# Clear sandbox contents (except .venv and .git)
|
||||||
|
find "$SANDBOX_DIR" -mindepth 1 -maxdepth 1 ! -name '.venv' ! -name '.git' -exec rm -rf {} +
|
||||||
|
|
||||||
|
# Copy template contents (including hidden files)
|
||||||
|
cp -r "$TEMPLATES_DIR/$template/." "$SANDBOX_DIR/"
|
||||||
|
|
||||||
|
# Mark which template was loaded
|
||||||
|
echo "$template" > "$MARKER_FILE"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Loaded template: $template${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " cd webber-sandbox"
|
||||||
|
if [ ! -d "$SANDBOX_DIR/.venv" ]; then
|
||||||
|
echo " python3.12 -m venv .venv"
|
||||||
|
fi
|
||||||
|
echo " source .venv/bin/activate"
|
||||||
|
echo " pip install -r requirements.txt"
|
||||||
|
echo ""
|
||||||
|
if [ -f "$SANDBOX_DIR/TASKS.md" ]; then
|
||||||
|
echo "Tasks available in TASKS.md"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_template() {
|
||||||
|
if [ ! -f "$MARKER_FILE" ]; then
|
||||||
|
echo -e "${RED}Error: No template loaded yet${NC}"
|
||||||
|
echo "Use './sandbox.sh load <template>' first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local template=$(cat "$MARKER_FILE")
|
||||||
|
echo "Resetting to template: $template"
|
||||||
|
load_template "$template"
|
||||||
|
}
|
||||||
|
|
||||||
|
save_template() {
|
||||||
|
local name="$1"
|
||||||
|
|
||||||
|
if [ -z "$name" ]; then
|
||||||
|
echo -e "${RED}Error: Template name required${NC}"
|
||||||
|
echo "Usage: ./sandbox.sh save <name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$TEMPLATES_DIR/$name" ]; then
|
||||||
|
echo -e "${YELLOW}Warning: Template '$name' already exists${NC}"
|
||||||
|
read -p "Overwrite? [y/N] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -rf "$TEMPLATES_DIR/$name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$TEMPLATES_DIR/$name"
|
||||||
|
|
||||||
|
# Copy sandbox contents (except .venv, .git, __pycache__)
|
||||||
|
rsync -a --exclude='.venv' --exclude='.git' --exclude='__pycache__' \
|
||||||
|
--exclude='*.pyc' --exclude='.pytest_cache' --exclude='.mypy_cache' \
|
||||||
|
"$SANDBOX_DIR/" "$TEMPLATES_DIR/$name/"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Saved template: $name${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
echo "Sandbox Status"
|
||||||
|
echo "=============="
|
||||||
|
echo ""
|
||||||
|
echo "Sandbox directory: $SANDBOX_DIR"
|
||||||
|
|
||||||
|
if [ -f "$MARKER_FILE" ]; then
|
||||||
|
echo "Current template: $(cat "$MARKER_FILE")"
|
||||||
|
else
|
||||||
|
echo "Current template: (none loaded)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$SANDBOX_DIR/.venv" ]; then
|
||||||
|
echo "Virtual env: exists"
|
||||||
|
else
|
||||||
|
echo "Virtual env: not created"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Contents:"
|
||||||
|
if [ -d "$SANDBOX_DIR" ]; then
|
||||||
|
ls -la "$SANDBOX_DIR" 2>/dev/null | tail -n +4
|
||||||
|
else
|
||||||
|
echo " (sandbox not initialized)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main command dispatch
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
list_templates
|
||||||
|
;;
|
||||||
|
load)
|
||||||
|
load_template "$2"
|
||||||
|
;;
|
||||||
|
reset)
|
||||||
|
reset_template
|
||||||
|
;;
|
||||||
|
save)
|
||||||
|
save_template "$2"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
-h|--help|"")
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unknown command: $1${NC}"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""
|
|
||||||
Application configuration via Pydantic Settings.
|
|
||||||
|
|
||||||
All settings loaded from environment variables or .env file.
|
|
||||||
"""
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
def _get_version() -> str:
|
|
||||||
"""Load version from pyproject.toml."""
|
|
||||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
|
||||||
try:
|
|
||||||
with open(pyproject_path, "rb") as f:
|
|
||||||
return tomllib.load(f).get("project", {}).get("version", "0.0.0")
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "0.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
__version__ = _get_version()
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings loaded from environment."""
|
|
||||||
|
|
||||||
# Application
|
|
||||||
app_name: str = "Webber"
|
|
||||||
app_version: str = __version__
|
|
||||||
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: Optional[str] = "http://192.168.86.149:8000"
|
|
||||||
internal_api_key: Optional[str] = None
|
|
||||||
|
|
||||||
# Tool execution
|
|
||||||
tool_timeout_seconds: int = 120
|
|
||||||
sandbox_enabled: bool = True
|
|
||||||
allowed_paths: list[str] = []
|
|
||||||
|
|
||||||
# Sessions
|
|
||||||
session_ttl_hours: int = 24
|
|
||||||
max_context_tokens: int = 128000
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
case_sensitive=False,
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Cached settings singleton."""
|
|
||||||
return Settings()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""
|
|
||||||
Pytest configuration and fixtures.
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient, ASGITransport
|
|
||||||
|
|
||||||
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,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
|
||||||
@@ -19,4 +19,7 @@ ENV PYTHONPATH=/app
|
|||||||
|
|
||||||
EXPOSE 8086
|
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"]
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8086", "--workers", "1"]
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# Webber Feature Coverage
|
||||||
|
|
||||||
|
> Tracking progress towards Claude Code-like functionality
|
||||||
|
|
||||||
|
## Current Status: ~65% Complete
|
||||||
|
|
||||||
|
Last updated: 2026-01-11
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| `EditFileTool` | ✅ | Find-and-replace with unique match validation |
|
||||||
|
| `WriteFileTool` | ✅ | Create/overwrite files with size limits |
|
||||||
|
| `BashTool` (full) | ✅ | Write-enabled shell with safety controls |
|
||||||
|
| `WebSearchTool` | ✅ | SearXNG integration for web search |
|
||||||
|
| Path validation | ✅ | `allowed_paths` restriction |
|
||||||
|
|
||||||
|
**Status:** Tools 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 |
|
||||||
|
| Streaming support | ✅ | `run_stream()` method with SSE |
|
||||||
|
|
||||||
|
**Available tools:** `read_file`, `glob_files`, `grep_content`, `bash_readonly`, `edit_file`, `write_file`, `bash`, `web_search`
|
||||||
|
|
||||||
|
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
||||||
|
|
||||||
|
### Phase 3: CLI Foundation ✅ Complete
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Typer + Rich setup | ✅ | Standalone `webber-cli/` package |
|
||||||
|
| `webber-cli --version` | ✅ | Shows version from pyproject.toml |
|
||||||
|
| Console theming | ✅ | Centralized color palette |
|
||||||
|
| Markdown rendering | ✅ | Rich markdown output |
|
||||||
|
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
||||||
|
|
||||||
|
### Phase 4: Agentic Loop ⚠️ Partial
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
||||||
|
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
||||||
|
| `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 |
|
||||||
|
| `POST /agents/stream` | ✅ | SSE streaming responses |
|
||||||
|
| `GET /agents/` | ✅ | List available agents |
|
||||||
|
| `GET /agents/{name}` | ✅ | Get agent info |
|
||||||
|
| Request/response schemas | ✅ | Pydantic models |
|
||||||
|
|
||||||
|
### Phase 6: Polish & Tests ✅ Complete
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| Tool unit tests | ✅ | 109 tests total |
|
||||||
|
| API endpoint tests | ✅ | 11 tests for agent routes |
|
||||||
|
| Health check tests | ✅ | 2 tests |
|
||||||
|
| Security tests | ✅ | 14 tests for path traversal, injection |
|
||||||
|
| Integration tests | ✅ | 10 tests with real LLM (requires Ollama) |
|
||||||
|
| E2E tests | ✅ | 12 tests against running API server |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Work: Remaining Features
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
| Feature | Category | Description | Complexity |
|
||||||
|
|---------|----------|-------------|------------|
|
||||||
|
| **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 |
|
||||||
|
|---------|----------|-------------|------------|
|
||||||
|
| **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
|
||||||
|
|
||||||
|
| Area | Current | Target | Status |
|
||||||
|
|------|---------|--------|--------|
|
||||||
|
| Tool unit tests | 109 | 109 | ✅ |
|
||||||
|
| API tests | 11 | 11 | ✅ |
|
||||||
|
| Security tests | 14 | 14 | ✅ |
|
||||||
|
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
||||||
|
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
||||||
|
|
||||||
|
**Test breakdown:**
|
||||||
|
- Read/Glob/Grep tools: 17 tests
|
||||||
|
- Edit/Write tools: 22 tests
|
||||||
|
- Bash tools: 22 tests
|
||||||
|
- Web search: 10 tests
|
||||||
|
- Gitignore filtering: 10 tests
|
||||||
|
- API endpoints: 11 tests
|
||||||
|
- Security: 14 tests
|
||||||
|
- Health checks: 2 tests
|
||||||
|
- Integration (LLM): 10 tests
|
||||||
|
- E2E (API): 12 tests
|
||||||
|
|
||||||
|
**Running tests:**
|
||||||
|
```bash
|
||||||
|
# Unit tests only (default)
|
||||||
|
pytest tests/
|
||||||
|
|
||||||
|
# Include integration tests (requires Ollama)
|
||||||
|
pytest tests/ --run-integration
|
||||||
|
|
||||||
|
# Include E2E tests (requires running API server)
|
||||||
|
pytest tests/ --run-e2e
|
||||||
|
|
||||||
|
# All tests
|
||||||
|
pytest tests/ --run-integration --run-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions Made
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Monorepo structure | `webber-api/`, `webber-cli/` | Separate packages, shared root |
|
||||||
|
| 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 |
|
||||||
|
| SearXNG for search | Self-hosted | Privacy, no API keys needed |
|
||||||
|
| SSE for streaming | Server-Sent Events | Simple, well-supported |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: What Works Now
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start dev server
|
||||||
|
cd webber-api && ./wakeup.sh
|
||||||
|
|
||||||
|
# CLI commands (from webber-cli/)
|
||||||
|
.venv/bin/webber-cli status # Check API connection
|
||||||
|
.venv/bin/webber-cli explore "find tests" # One-shot exploration
|
||||||
|
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
|
||||||
|
.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":"."}'
|
||||||
|
|
||||||
|
# Streaming endpoint
|
||||||
|
curl -N http://localhost:8095/agents/stream \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools Available
|
||||||
|
|
||||||
|
| Tool | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `read_file` | Read | Read file contents with line numbers |
|
||||||
|
| `glob_files` | Read | Find files by pattern |
|
||||||
|
| `grep_content` | Read | Search file contents with regex |
|
||||||
|
| `bash_readonly` | Read | Safe bash commands (ls, git status, etc.) |
|
||||||
|
| `edit_file` | Write | Find-and-replace editing |
|
||||||
|
| `write_file` | Write | Create/overwrite files |
|
||||||
|
| `bash` | Write | Full bash with safety controls |
|
||||||
|
| `web_search` | External | Search web via SearXNG |
|
||||||
@@ -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,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,77 @@
|
|||||||
|
[project]
|
||||||
|
name = "webber-api"
|
||||||
|
version = "0.3.1"
|
||||||
|
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 --strict-markers"
|
||||||
|
markers = [
|
||||||
|
"integration: marks tests as integration tests (require Ollama to be running)",
|
||||||
|
"e2e: marks tests as end-to-end tests (require API server to be running)",
|
||||||
|
"slow: marks tests as slow (may take > 10 seconds)",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::pytest.PytestUnraisableExceptionWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -15,3 +15,6 @@ pip-audit~=2.9.0
|
|||||||
|
|
||||||
# Type checking
|
# Type checking
|
||||||
mypy~=1.19.1
|
mypy~=1.19.1
|
||||||
|
|
||||||
|
# Linting and formatting
|
||||||
|
ruff~=0.9.4
|
||||||
@@ -17,6 +17,11 @@ pydantic-ai~=1.40.0
|
|||||||
httpx~=0.28.1
|
httpx~=0.28.1
|
||||||
aiofiles~=25.1.0
|
aiofiles~=25.1.0
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
typer~=0.15.0
|
||||||
|
rich~=13.9.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-multipart~=0.0.21
|
python-multipart~=0.0.21
|
||||||
python-dotenv~=1.2.1
|
python-dotenv~=1.2.1
|
||||||
|
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,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,184 @@
|
|||||||
|
"""
|
||||||
|
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 collections.abc import AsyncIterator
|
||||||
|
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
|
||||||
|
|
||||||
|
async def run_stream(
|
||||||
|
self, prompt: str, **kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""
|
||||||
|
Execute the agent with streaming output.
|
||||||
|
|
||||||
|
Default implementation falls back to non-streaming run().
|
||||||
|
Override this for true streaming support.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Text chunks as they become available
|
||||||
|
"""
|
||||||
|
# Default: fall back to non-streaming
|
||||||
|
result = await self.run(prompt, **kwargs)
|
||||||
|
yield result
|
||||||
|
|
||||||
|
|
||||||
|
# === 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,166 @@
|
|||||||
|
"""
|
||||||
|
Explore Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
Fast codebase exploration with read-only tools.
|
||||||
|
Uses sanitized Ollama provider for reliable tool calling.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def run_stream(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""
|
||||||
|
Run the explore agent with streaming output.
|
||||||
|
|
||||||
|
Yields text chunks as they become available.
|
||||||
|
"""
|
||||||
|
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_stream"):
|
||||||
|
try:
|
||||||
|
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
||||||
|
async for chunk in result.stream_text():
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Explore agent stream 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)
|
||||||
|
|
||||||
|
|
||||||
|
async def explore_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Run exploration query with streaming."""
|
||||||
|
async for chunk in explore_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield chunk
|
||||||
@@ -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,313 @@
|
|||||||
|
"""
|
||||||
|
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.file.edit import EditFileTool
|
||||||
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
from src.domains.tools.search.web import WebSearchTool
|
||||||
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# === Write-capable tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def edit_file(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
file_path: str,
|
||||||
|
old_string: str,
|
||||||
|
new_string: str,
|
||||||
|
replace_all: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Make targeted edits to a file using find-and-replace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to edit
|
||||||
|
old_string: The exact text to find and replace (must exist in file)
|
||||||
|
new_string: The replacement text
|
||||||
|
replace_all: If True, replace all occurrences. If False (default),
|
||||||
|
old_string must be unique (appear exactly once).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with diff preview, or error.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- old_string must exactly match file content (including whitespace)
|
||||||
|
- By default, old_string must appear exactly once (for safety)
|
||||||
|
- Always read the file first to verify exact content before editing
|
||||||
|
"""
|
||||||
|
tool = EditFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
old_string=old_string,
|
||||||
|
new_string=new_string,
|
||||||
|
replace_all=replace_all
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def write_file(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
file_path: str,
|
||||||
|
content: str
|
||||||
|
) -> str:
|
||||||
|
"""Create a new file or overwrite an existing file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to create/write
|
||||||
|
content: The content to write to the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with file path and size.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Parent directory must exist (use mkdir first if needed)
|
||||||
|
- For editing existing files, prefer edit_file instead
|
||||||
|
- Will overwrite existing files without confirmation
|
||||||
|
"""
|
||||||
|
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def bash(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
command: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: int = 60
|
||||||
|
) -> str:
|
||||||
|
"""Execute a bash command with write capabilities.
|
||||||
|
|
||||||
|
ALLOWED:
|
||||||
|
- File operations: ls, find, mkdir, touch, cp, mv, rm (single files)
|
||||||
|
- Git (full): git add, git commit, git checkout, git merge, git pull
|
||||||
|
- Python: python, pip install, pytest, mypy, ruff
|
||||||
|
- Text processing: grep, awk, sed, sort
|
||||||
|
- Command chaining: && and || are allowed
|
||||||
|
|
||||||
|
FORBIDDEN:
|
||||||
|
- sudo, su (privilege escalation)
|
||||||
|
- Network: curl, wget, ssh, scp, rsync
|
||||||
|
- Dangerous: rm -rf, chmod 777, dd, mkfs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The bash command to execute
|
||||||
|
cwd: Working directory (default: agent working directory)
|
||||||
|
timeout: Timeout in seconds (default: 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command output or error message.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "mkdir -p src/utils" creates directory
|
||||||
|
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||||
|
- "pytest tests/ -v" runs tests
|
||||||
|
- "rm old_file.py" removes single file
|
||||||
|
"""
|
||||||
|
tool = BashTool(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()
|
||||||
|
|
||||||
|
# === Web search ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def web_search(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
query: str,
|
||||||
|
num_results: int = 5,
|
||||||
|
categories: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Search the web for current information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query (e.g., "Python 3.12 new features")
|
||||||
|
num_results: Number of results to return (1-10, default: 5)
|
||||||
|
categories: Optional category filter ("general", "it", "news", "science")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Search results with titles, URLs, and snippets.
|
||||||
|
|
||||||
|
Use this for:
|
||||||
|
- Current events or recent information
|
||||||
|
- Documentation updates since your training
|
||||||
|
- Facts you're uncertain about
|
||||||
|
- Technical references with URLs
|
||||||
|
|
||||||
|
IMPORTANT: Always include a "Sources:" section with URLs in your response.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- query="FastAPI best practices 2024"
|
||||||
|
- query="CVE-2024" categories="it"
|
||||||
|
"""
|
||||||
|
tool = WebSearchTool()
|
||||||
|
result = await tool.execute(
|
||||||
|
query=query,
|
||||||
|
num_results=num_results,
|
||||||
|
categories=categories
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
REST API routes for agents.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
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.post("/stream")
|
||||||
|
@logged()
|
||||||
|
async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||||
|
"""
|
||||||
|
Run an agent with streaming response.
|
||||||
|
|
||||||
|
Returns Server-Sent Events (SSE) with text chunks.
|
||||||
|
Event types:
|
||||||
|
- "chunk": Text chunk from the agent
|
||||||
|
- "done": Stream complete
|
||||||
|
- "error": Error occurred
|
||||||
|
"""
|
||||||
|
agent = get_agent(request.agent_type)
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unknown agent type: {request.agent_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
try:
|
||||||
|
async for chunk in agent.run_stream(
|
||||||
|
request.prompt,
|
||||||
|
working_dir=request.working_dir,
|
||||||
|
):
|
||||||
|
# SSE format: data: {json}\n\n
|
||||||
|
event = {"event": "chunk", "data": chunk}
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
|
# Signal completion
|
||||||
|
yield f"data: {json.dumps({'event': 'done'})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Stream error: {e}")
|
||||||
|
error_event = {"event": "error", "data": str(e)}
|
||||||
|
yield f"data: {json.dumps(error_event)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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]
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Health check routes.
|
Health check routes.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from src.domains.health.controller import health_controller
|
from src.domains.health.controller import health_controller
|
||||||
|
|
||||||
@@ -7,8 +7,9 @@ main.py only includes this root_router.
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from src.domains.health.router import router as health_router
|
from src.domains.health.router import router as health_router
|
||||||
|
from src.domains.agents.router import router as agents_router
|
||||||
|
|
||||||
# from src.domains.auth.router import router as auth_router
|
# from src.domains.auth.router import router as auth_router
|
||||||
# from src.domains.agents.router import router as agents_router
|
|
||||||
# from src.domains.tools.router import router as tools_router
|
# from src.domains.tools.router import router as tools_router
|
||||||
|
|
||||||
root_router = APIRouter()
|
root_router = APIRouter()
|
||||||
@@ -16,11 +17,11 @@ root_router = APIRouter()
|
|||||||
# Health (no prefix - root level)
|
# Health (no prefix - root level)
|
||||||
root_router.include_router(health_router)
|
root_router.include_router(health_router)
|
||||||
|
|
||||||
|
# Agents domain (prefix defined in router)
|
||||||
|
root_router.include_router(agents_router)
|
||||||
|
|
||||||
# Auth domain
|
# Auth domain
|
||||||
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
# root_router.include_router(auth_router, prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
# Agents domain
|
|
||||||
# root_router.include_router(agents_router, prefix="/agents", tags=["Agents"])
|
|
||||||
|
|
||||||
# Tools domain
|
# Tools domain
|
||||||
# root_router.include_router(tools_router, prefix="/tools", tags=["Tools"])
|
# root_router.include_router(tools_router, prefix="/tools", tags=["Tools"])
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
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, EditFileTool, WriteFileTool
|
||||||
|
from src.domains.tools.search import GrepContentTool, WebSearchTool
|
||||||
|
from src.domains.tools.shell import BashReadOnlyTool, BashTool
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseTool",
|
||||||
|
"ToolResult",
|
||||||
|
"ReadFileTool",
|
||||||
|
"GlobFilesTool",
|
||||||
|
"EditFileTool",
|
||||||
|
"WriteFileTool",
|
||||||
|
"GrepContentTool",
|
||||||
|
"WebSearchTool",
|
||||||
|
"BashReadOnlyTool",
|
||||||
|
"BashTool",
|
||||||
|
]
|
||||||
@@ -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,9 @@
|
|||||||
|
"""
|
||||||
|
File operation tools.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.file.edit import EditFileTool
|
||||||
|
from src.domains.tools.file.write import WriteFileTool
|
||||||
|
|
||||||
|
__all__ = ["ReadFileTool", "GlobFilesTool", "EditFileTool", "WriteFileTool"]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
File editing tool with find-and-replace functionality.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
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__)
|
||||||
|
|
||||||
|
# Binary file extensions to skip
|
||||||
|
BINARY_EXTENSIONS = {
|
||||||
|
'.pyc', '.pyo', '.so', '.o', '.a', '.lib', '.dll', '.exe',
|
||||||
|
'.bin', '.dat', '.db', '.sqlite', '.sqlite3',
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.bmp', '.webp',
|
||||||
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
||||||
|
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
|
||||||
|
'.mp3', '.mp4', '.avi', '.mov', '.wav',
|
||||||
|
'.woff', '.woff2', '.ttf', '.eot',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EditFileTool(BaseTool):
|
||||||
|
"""
|
||||||
|
Edit files using find-and-replace.
|
||||||
|
|
||||||
|
Safely modifies files by finding exact text matches and replacing them.
|
||||||
|
Includes safety checks to prevent accidental edits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "edit_file"
|
||||||
|
description = """Make targeted edits to a file using find-and-replace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to edit
|
||||||
|
old_string: The exact text to find and replace (must exist in file)
|
||||||
|
new_string: The replacement text
|
||||||
|
replace_all: If True, replace all occurrences. If False (default),
|
||||||
|
old_string must be unique in the file (appear exactly once).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with diff preview showing changes, or error.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- The old_string must exactly match text in the file (including whitespace/indentation)
|
||||||
|
- By default, old_string must appear exactly once in the file (for safety)
|
||||||
|
- Use replace_all=True only when you intentionally want to replace all occurrences
|
||||||
|
- Always read the file first to verify exact content before editing
|
||||||
|
- Cannot edit binary files
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Fix a bug: old_string="return x + y", new_string="return x * y"
|
||||||
|
- Rename function: old_string="def old_name(", new_string="def new_name("
|
||||||
|
- Add import: old_string="import os", new_string="import os\\nimport sys"
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
max_file_size: int = 1_000_000, # 1MB
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize EditFileTool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allowed_paths: List of allowed directory prefixes (empty = no restrictions)
|
||||||
|
max_file_size: Maximum file size to edit in bytes
|
||||||
|
"""
|
||||||
|
self.allowed_paths = allowed_paths or []
|
||||||
|
self.max_file_size = max_file_size
|
||||||
|
|
||||||
|
def _is_binary_file(self, path: Path) -> bool:
|
||||||
|
"""Check if file is likely binary based on extension."""
|
||||||
|
return path.suffix.lower() in BINARY_EXTENSIONS
|
||||||
|
|
||||||
|
def _generate_diff(
|
||||||
|
self,
|
||||||
|
original: str,
|
||||||
|
modified: str,
|
||||||
|
file_path: str
|
||||||
|
) -> str:
|
||||||
|
"""Generate a unified diff between original and modified content."""
|
||||||
|
original_lines = original.splitlines(keepends=True)
|
||||||
|
modified_lines = modified.splitlines(keepends=True)
|
||||||
|
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
original_lines,
|
||||||
|
modified_lines,
|
||||||
|
fromfile=f"a/{Path(file_path).name}",
|
||||||
|
tofile=f"b/{Path(file_path).name}",
|
||||||
|
lineterm=""
|
||||||
|
)
|
||||||
|
return "".join(diff)
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
old_string: str,
|
||||||
|
new_string: str,
|
||||||
|
replace_all: bool = False
|
||||||
|
) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Edit a file by replacing old_string with new_string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file
|
||||||
|
old_string: Text to find (must exist)
|
||||||
|
new_string: Replacement text
|
||||||
|
replace_all: Replace all occurrences (default: False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolResult with diff preview or error
|
||||||
|
"""
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Check for binary files
|
||||||
|
if self._is_binary_file(path):
|
||||||
|
return self._error(f"Cannot edit binary file: {file_path}")
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
file_size = path.stat().st_size
|
||||||
|
if file_size > self.max_file_size:
|
||||||
|
return self._error(
|
||||||
|
f"File too large ({file_size} bytes). Max: {self.max_file_size} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if not old_string:
|
||||||
|
return self._error("old_string cannot be empty")
|
||||||
|
|
||||||
|
if old_string == new_string:
|
||||||
|
return self._error("old_string and new_string are identical")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read file content
|
||||||
|
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
content = await f.read()
|
||||||
|
|
||||||
|
# Check if old_string exists
|
||||||
|
count = content.count(old_string)
|
||||||
|
if count == 0:
|
||||||
|
return self._error(
|
||||||
|
f"old_string not found in file. "
|
||||||
|
f"Make sure to match exact whitespace and indentation."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check uniqueness if replace_all is False
|
||||||
|
if not replace_all and count > 1:
|
||||||
|
return self._error(
|
||||||
|
f"old_string appears {count} times in file. "
|
||||||
|
f"Use replace_all=True to replace all, or provide a more specific string."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform replacement
|
||||||
|
if replace_all:
|
||||||
|
modified = content.replace(old_string, new_string)
|
||||||
|
else:
|
||||||
|
modified = content.replace(old_string, new_string, 1)
|
||||||
|
|
||||||
|
# Generate diff for preview
|
||||||
|
diff = self._generate_diff(content, modified, file_path)
|
||||||
|
|
||||||
|
# Write modified content
|
||||||
|
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
||||||
|
await f.write(modified)
|
||||||
|
|
||||||
|
replacements = count if replace_all else 1
|
||||||
|
return self._success(
|
||||||
|
data=f"Successfully edited {file_path}\n\n{diff}",
|
||||||
|
replacements=replacements,
|
||||||
|
file_path=str(path.resolve())
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
return self._error(f"Permission denied: {file_path}")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
return self._error(f"Unable to decode file (binary?): {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error editing file: {file_path}")
|
||||||
|
return self._error(f"Error editing file: {e}")
|
||||||
@@ -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,125 @@
|
|||||||
|
"""
|
||||||
|
File writing tool for creating and overwriting files.
|
||||||
|
"""
|
||||||
|
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 WriteFileTool(BaseTool):
|
||||||
|
"""
|
||||||
|
Create new files or overwrite existing files.
|
||||||
|
|
||||||
|
Validates paths are within allowed directories and enforces size limits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "write_file"
|
||||||
|
description = """Create a new file or overwrite an existing file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file to create/write
|
||||||
|
content: The content to write to the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message with file path and size, or error.
|
||||||
|
Includes a warning if overwriting an existing file.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Use absolute paths only
|
||||||
|
- Parent directory must exist (will not create directories)
|
||||||
|
- Will overwrite existing files without confirmation
|
||||||
|
- For targeted edits to existing files, use edit_file instead
|
||||||
|
- Maximum content size: 1MB
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Create new module: file_path="/project/src/utils.py", content="def helper(): pass"
|
||||||
|
- Create config: file_path="/project/config.json", content='{"key": "value"}'
|
||||||
|
- Create test: file_path="/project/tests/test_new.py", content="def test_example(): assert True"
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
max_content_size: int = 1_000_000, # 1MB
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize WriteFileTool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allowed_paths: List of allowed directory prefixes (empty = no restrictions)
|
||||||
|
max_content_size: Maximum content size in bytes
|
||||||
|
"""
|
||||||
|
self.allowed_paths = allowed_paths or []
|
||||||
|
self.max_content_size = max_content_size
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
file_path: str,
|
||||||
|
content: str
|
||||||
|
) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Write content to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute path to the file
|
||||||
|
content: Content to write
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolResult with success info or error
|
||||||
|
"""
|
||||||
|
path = Path(file_path).resolve()
|
||||||
|
|
||||||
|
# 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 content size
|
||||||
|
content_bytes = len(content.encode('utf-8'))
|
||||||
|
if content_bytes > self.max_content_size:
|
||||||
|
return self._error(
|
||||||
|
f"Content too large ({content_bytes} bytes). "
|
||||||
|
f"Max: {self.max_content_size} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check parent directory exists
|
||||||
|
if not path.parent.exists():
|
||||||
|
return self._error(
|
||||||
|
f"Parent directory does not exist: {path.parent}. "
|
||||||
|
f"Create it first with mkdir."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.parent.is_dir():
|
||||||
|
return self._error(f"Parent path is not a directory: {path.parent}")
|
||||||
|
|
||||||
|
# Check if we're overwriting
|
||||||
|
overwritten = path.exists() and path.is_file()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write the file
|
||||||
|
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
|
||||||
|
await f.write(content)
|
||||||
|
|
||||||
|
# Count lines for metadata
|
||||||
|
lines = content.count('\n') + (1 if content and not content.endswith('\n') else 0)
|
||||||
|
|
||||||
|
status = "Overwrote" if overwritten else "Created"
|
||||||
|
return self._success(
|
||||||
|
data=f"{status} {path} ({content_bytes} bytes, {lines} lines)",
|
||||||
|
file_path=str(path),
|
||||||
|
file_size=content_bytes,
|
||||||
|
lines=lines,
|
||||||
|
overwritten=overwritten
|
||||||
|
)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
return self._error(f"Permission denied: {file_path}")
|
||||||
|
except OSError as e:
|
||||||
|
return self._error(f"OS error writing file: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error writing file: {file_path}")
|
||||||
|
return self._error(f"Error writing 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,7 @@
|
|||||||
|
"""
|
||||||
|
Search tools.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
from src.domains.tools.search.web import WebSearchTool
|
||||||
|
|
||||||
|
__all__ = ["GrepContentTool", "WebSearchTool"]
|
||||||
@@ -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,169 @@
|
|||||||
|
"""
|
||||||
|
Web search tool using SearXNG.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import logged, get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchResult:
|
||||||
|
"""A single search result."""
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
content: str
|
||||||
|
engine: str
|
||||||
|
published_date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearchTool(BaseTool):
|
||||||
|
"""
|
||||||
|
Search the web using SearXNG metasearch engine.
|
||||||
|
|
||||||
|
Returns relevant web results for queries about current events,
|
||||||
|
documentation, or anything beyond the LLM's knowledge cutoff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "web_search"
|
||||||
|
description = """Search the web for current information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
num_results: Maximum results to return (default: 5, max: 10)
|
||||||
|
engines: Comma-separated engine list (optional, e.g., "google,brave,duckduckgo")
|
||||||
|
categories: Search category (optional: "general", "images", "news", "science", "it")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of search results with title, URL, and snippet.
|
||||||
|
Include a "Sources:" section with URLs in your response.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- query="Python 3.12 new features" - Find latest Python docs
|
||||||
|
- query="FastAPI best practices 2024" - Find recent tutorials
|
||||||
|
- query="CVE-2024" categories="it" - Search IT/security news
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Use this for current events, recent documentation, or facts you're unsure about
|
||||||
|
- Always cite sources with URLs in your response
|
||||||
|
- Today's date is {date} - use current year in queries for recent info
|
||||||
|
""".format(date=datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
searxng_url: str | None = None,
|
||||||
|
timeout: int | None = None,
|
||||||
|
max_results: int = 10,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize WebSearchTool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
searxng_url: SearXNG instance URL (default: from config)
|
||||||
|
timeout: Request timeout in seconds (default: from config)
|
||||||
|
max_results: Maximum results to return
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
||||||
|
self.timeout = timeout or settings.searxng_timeout
|
||||||
|
self.max_results = max_results
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
num_results: int = 5,
|
||||||
|
engines: str | None = None,
|
||||||
|
categories: str | None = None,
|
||||||
|
) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Execute web search.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query
|
||||||
|
num_results: Number of results (1-10)
|
||||||
|
engines: Specific engines to use
|
||||||
|
categories: Search category
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolResult with search results
|
||||||
|
"""
|
||||||
|
if not query or not query.strip():
|
||||||
|
return self._error("Query cannot be empty")
|
||||||
|
|
||||||
|
num_results = min(max(1, num_results), self.max_results)
|
||||||
|
|
||||||
|
# Build SearXNG API request
|
||||||
|
params = {
|
||||||
|
"q": query.strip(),
|
||||||
|
"format": "json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if engines:
|
||||||
|
params["engines"] = engines
|
||||||
|
if categories:
|
||||||
|
params["categories"] = categories
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.searxng_url}/search",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return self._error(f"Search timed out after {self.timeout}s")
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
return self._error(f"Search request failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Unexpected search error: {e}")
|
||||||
|
return self._error(f"Search error: {e}")
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
raw_results = data.get("results", [])[:num_results]
|
||||||
|
|
||||||
|
if not raw_results:
|
||||||
|
return self._success(
|
||||||
|
f"No results found for: {query}",
|
||||||
|
result_count=0,
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format results for LLM consumption
|
||||||
|
results = []
|
||||||
|
for r in raw_results:
|
||||||
|
result = SearchResult(
|
||||||
|
title=r.get("title", "Untitled"),
|
||||||
|
url=r.get("url", ""),
|
||||||
|
content=r.get("content", "No description"),
|
||||||
|
engine=r.get("engine", "unknown"),
|
||||||
|
published_date=r.get("publishedDate"),
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Format as readable text
|
||||||
|
output_lines = [f"Search results for: {query}", ""]
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
output_lines.append(f"{i}. **{r.title}**")
|
||||||
|
output_lines.append(f" URL: {r.url}")
|
||||||
|
output_lines.append(f" {r.content}")
|
||||||
|
if r.published_date:
|
||||||
|
output_lines.append(f" Published: {r.published_date}")
|
||||||
|
output_lines.append("")
|
||||||
|
|
||||||
|
return self._success(
|
||||||
|
"\n".join(output_lines),
|
||||||
|
result_count=len(results),
|
||||||
|
query=query,
|
||||||
|
engines_used=list({r.engine for r in results}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Shell execution tools.
|
||||||
|
"""
|
||||||
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
__all__ = ["BashReadOnlyTool", "BashTool"]
|
||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user