Compare commits
15
Commits
api/v0.3.2
...
api/v1.0.1
@@ -6,7 +6,7 @@ on:
|
|||||||
- 'api/v*'
|
- 'api/v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: git.schweitz.internal/jpmschweitzer/webber-api
|
IMAGE_NAME: git.schweitz.net/jpmschweitzer/webber-api
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.schweitz.internal
|
registry: git.schweitz.net
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
|||||||
@@ -88,11 +88,37 @@ cd webber-cli
|
|||||||
# Check API connection
|
# Check API connection
|
||||||
.venv/bin/webber-cli status
|
.venv/bin/webber-cli status
|
||||||
|
|
||||||
# Explore a directory
|
# Interactive chat (default mode - full capabilities)
|
||||||
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
|
|
||||||
|
|
||||||
# Interactive chat mode
|
|
||||||
.venv/bin/webber-cli chat -d ../webber-sandbox
|
.venv/bin/webber-cli chat -d ../webber-sandbox
|
||||||
|
|
||||||
|
# Read-only mode (safe exploration and planning)
|
||||||
|
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
|
||||||
|
|
||||||
|
# Auto-accept mode (no approval prompts - use with caution)
|
||||||
|
.venv/bin/webber-cli chat --mode auto_accept -d ../webber-sandbox
|
||||||
|
|
||||||
|
# List previous sessions
|
||||||
|
.venv/bin/webber-cli sessions
|
||||||
|
|
||||||
|
# Resume a previous session
|
||||||
|
.venv/bin/webber-cli chat --resume <session-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI Features:**
|
||||||
|
- **Tab completion** for commands and file paths
|
||||||
|
- **Command history** persisted to `~/.webber_history`
|
||||||
|
- **Session persistence** - conversations saved and resumable
|
||||||
|
- **Config file** - persistent settings via `~/.webber/config.toml`
|
||||||
|
- **Runtime mode switching** via `mode plan|default|auto_accept`
|
||||||
|
- **Directory navigation** via `cd <path>`
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
# Show current config
|
||||||
|
.venv/bin/webber-cli config
|
||||||
|
|
||||||
|
# Initialize config file with defaults
|
||||||
|
.venv/bin/webber-cli config --init
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The API server must be running for CLI commands to work.
|
**Note:** The API server must be running for CLI commands to work.
|
||||||
@@ -153,9 +179,10 @@ pytest tests/ -v
|
|||||||
# 1. Load the template
|
# 1. Load the template
|
||||||
./sandbox.sh load calculator-cli
|
./sandbox.sh load calculator-cli
|
||||||
|
|
||||||
# 2. Have Webber explore it
|
# 2. Have Webber explore it (plan mode = read-only)
|
||||||
cd webber-cli
|
cd webber-cli
|
||||||
.venv/bin/webber-cli explore "find all bugs in the code" -d ../webber-sandbox
|
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
|
||||||
|
# Then ask: "find all bugs in the code"
|
||||||
|
|
||||||
# 3. Check TASKS.md for expected bugs
|
# 3. Check TASKS.md for expected bugs
|
||||||
cat ../webber-sandbox/TASKS.md
|
cat ../webber-sandbox/TASKS.md
|
||||||
@@ -192,14 +219,23 @@ Uses prefixed tags:
|
|||||||
**NEVER push a tag before updating version files.** Follow this exact order:
|
**NEVER push a tag before updating version files.** Follow this exact order:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Update version in pyproject.toml
|
# For API releases:
|
||||||
# 2. Update CHANGELOG.md with release notes
|
# 1. Update version in webber-api/pyproject.toml
|
||||||
|
# 2. Update webber-api/CHANGELOG.md with release notes
|
||||||
# 3. Commit the version bump
|
# 3. Commit the version bump
|
||||||
git add -A && git commit -m "chore: release api vX.Y.Z"
|
git add -A && git commit -m "chore: release api vX.Y.Z"
|
||||||
|
|
||||||
# 4. Create the tag (AFTER the commit)
|
# 4. Create the tag (AFTER the commit)
|
||||||
git tag api/vX.Y.Z
|
git tag api/vX.Y.Z
|
||||||
|
# 5. Push everything together
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
# For CLI releases:
|
||||||
|
# 1. Update version in webber-cli/pyproject.toml
|
||||||
|
# 2. Update webber-cli/CHANGELOG.md with release notes
|
||||||
|
# 3. Commit the version bump
|
||||||
|
git add -A && git commit -m "chore: release cli vX.Y.Z"
|
||||||
|
# 4. Create the tag (AFTER the commit)
|
||||||
|
git tag cli/vX.Y.Z
|
||||||
# 5. Push everything together
|
# 5. Push everything together
|
||||||
git push origin main --tags
|
git push origin main --tags
|
||||||
```
|
```
|
||||||
@@ -249,7 +285,5 @@ cd webber-api
|
|||||||
## Known Limitations
|
## Known Limitations
|
||||||
|
|
||||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
|
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.
|
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
|
||||||
|
|||||||
+6
-84
@@ -1,88 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
This monorepo maintains separate changelogs for each package:
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
|
||||||
|
|
||||||
## [Unreleased]
|
Each package is versioned independently using prefixed git tags:
|
||||||
|
- `api/vX.Y.Z` for API releases
|
||||||
## [0.3.2] - 2026-01-11
|
- `cli/vX.Y.Z` for CLI releases
|
||||||
|
|
||||||
### Added
|
|
||||||
- Mandatory release procedure documentation in AGENTS.md
|
|
||||||
|
|
||||||
## [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
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- CI/CD pipeline credentials configured
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-01-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Reference prompts from claude-code-system-prompts for all agent types
|
|
||||||
- Detailed documentation for Explore, Plan, and Task agents
|
|
||||||
- Detailed documentation for File, Shell, and Search tools
|
|
||||||
- Utility prompts (TodoWrite, AskUserQuestion, conversation summarization, etc.)
|
|
||||||
- Security review prompt for code analysis
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Expanded agents/README.md with capabilities and use cases
|
|
||||||
- Expanded tools/README.md with parameter details and behaviors
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-01-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Initial FastAPI boilerplate setup
|
|
||||||
- Domain-based project structure (src/domains/, src/shared/)
|
|
||||||
- BaseController pattern with lazy router instantiation
|
|
||||||
- Pydantic Settings configuration with env file support
|
|
||||||
- Logger decorator with temporal benchmarking and trace IDs
|
|
||||||
- UserProvider singleton for request-scoped context
|
|
||||||
- Custom exception hierarchy
|
|
||||||
- Health endpoints (/, /health)
|
|
||||||
- Placeholder domains for agents (explore, plan, task)
|
|
||||||
- Placeholder domains for tools (file, shell, search)
|
|
||||||
- Placeholder domain for auth (tatlock integration)
|
|
||||||
- CI/CD workflow for Gitea with Docker build and Watchtower deployment
|
|
||||||
- Dockerfile for containerized deployment
|
|
||||||
- CVE-checked dependencies (2026-01-09)
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ A Claude Code-inspired development assistant powered by local LLMs via Ollama.
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Explore Agent** - Search, read, and understand codebases
|
- **3 Agents** - Explore (read-only), Plan (architecture), Task (orchestrator)
|
||||||
- **8 Tools** - File read/write, glob, grep, bash, web search
|
- **8 Tools** - File read/write/edit, glob, grep, bash, web search
|
||||||
|
- **Conversations** - Multi-turn memory with context summarization
|
||||||
- **Streaming** - Real-time response display
|
- **Streaming** - Real-time response display
|
||||||
- **Self-hosted** - Runs on your own hardware with Ollama
|
- **Self-hosted** - Runs on your own hardware with Ollama
|
||||||
|
|
||||||
@@ -74,11 +75,33 @@ webber-cli chat -d /path/to/project
|
|||||||
| `bash` | Full bash with safety controls |
|
| `bash` | Full bash with safety controls |
|
||||||
| `web_search` | Search web via SearXNG |
|
| `web_search` | Search web via SearXNG |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
| Agent | Purpose | Tools |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| **Explore** | Fast codebase navigation, search | Read-only (glob, grep, read, bash_readonly) |
|
||||||
|
| **Plan** | Design implementation strategies | Read-only (same as Explore) |
|
||||||
|
| **Task** | Autonomous multi-step execution | All tools + spawn_agent |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stateless agent execution
|
||||||
|
POST /agents/run # Execute agent, get response
|
||||||
|
POST /agents/stream # Execute with SSE streaming
|
||||||
|
GET /agents/ # List available agents
|
||||||
|
|
||||||
|
# Stateful conversations (multi-turn memory)
|
||||||
|
POST /conversations/ # Create conversation
|
||||||
|
GET /conversations/ # List conversations
|
||||||
|
POST /conversations/{id}/messages # Add message, get agent response
|
||||||
|
```
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
This project uses prefixed tags for independent release cycles:
|
This project uses prefixed tags for independent release cycles:
|
||||||
|
|
||||||
- `api/v0.3.0` - Triggers API Docker build and deployment
|
- `api/v0.4.0` - Triggers API Docker build and deployment
|
||||||
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
- `cli/v0.1.0` - Triggers CLI installer build (future)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ OLLAMA_AGENT_MODEL=mistral-nemo-large:latest
|
|||||||
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
OLLAMA_EMBED_MODEL=nomic-embed-text:latest
|
||||||
|
|
||||||
# Auth - Tatlock integration (optional)
|
# Auth - Tatlock integration (optional)
|
||||||
# TATLOCK_API_URL=http://192.168.86.149:8000
|
# TATLOCK_API_URL=http://tatlock:8000
|
||||||
# INTERNAL_API_KEY=your-internal-key
|
# INTERNAL_API_KEY=your-internal-key
|
||||||
|
|
||||||
|
# Web search - SearXNG (container name on docker-dataplane; internal port 8080)
|
||||||
|
# SEARXNG_URL=http://searxng:8080
|
||||||
|
|
||||||
# Tool execution
|
# Tool execution
|
||||||
TOOL_TIMEOUT_SECONDS=120
|
TOOL_TIMEOUT_SECONDS=120
|
||||||
SANDBOX_ENABLED=true
|
SANDBOX_ENABLED=true
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Changelog - Webber API
|
||||||
|
|
||||||
|
All notable changes to the Webber API will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-07-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default `TATLOCK_API_URL` and `SEARXNG_URL` now use docker container names (`http://tatlock:8000`, `http://searxng:8080`) instead of host IP:port, for container-to-container traffic on the docker-dataplane network
|
||||||
|
- CI workflow now pushes Docker images via the `git.schweitz.net` registry route
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Event-based streaming for task agent (`StreamEvent` objects instead of raw text)
|
||||||
|
- New `tools_streaming.py` with all tools emitting structured events
|
||||||
|
- Event types: `tool_start`, `tool_done`, `thinking`, `response`, `error`, `done`
|
||||||
|
- Retry logic when LLM responds without calling tools (max 2 retries)
|
||||||
|
- Tracks `tools_called` counter on TaskContext
|
||||||
|
- Stronger retry prompt forces tool use
|
||||||
|
- Working directory context injected into all agent prompts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Hardened system prompts to enforce tool use before responding
|
||||||
|
- Added "CRITICAL RULE" section requiring tool calls first
|
||||||
|
- Made "MANDATORY WORKFLOW" more emphatic
|
||||||
|
- Updated explore and plan agents with `_build_prompt_with_context()` method
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Agent path hallucination - now explicitly communicates working directory to LLM
|
||||||
|
|
||||||
|
### Note
|
||||||
|
- Project paused: Local LLMs (Mistral Nemo 12B on available hardware) are not capable enough for reliable agentic tool use. Models frequently hallucinate responses instead of calling tools, even with prompt hardening and retry logic. Would require larger models (70B+) or cloud API integration to continue.
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Retry logic for transient failures with exponential backoff
|
||||||
|
- `src/shared/retry.py` - `@with_retry` decorator and `retry_async()` function
|
||||||
|
- Retries on: timeout, connection errors, HTTP 429/5xx
|
||||||
|
- Configurable: `RETRY_MAX_ATTEMPTS`, `RETRY_BASE_DELAY`, `RETRY_MAX_DELAY`
|
||||||
|
- Web search tool now automatically retries on network failures
|
||||||
|
- 29 retry tests (205 total tests passing)
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Replace `litellm` with `tiktoken` for token counting (dependency conflict with pydantic-ai)
|
||||||
|
- Update documentation (README.md, architecture.md) with conversation layer info
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Conversation persistence layer with SQLAlchemy async
|
||||||
|
- Database models: `Conversation`, `Message` with UUID primary keys
|
||||||
|
- SQLite (dev) and PostgreSQL (prod) support via async engines
|
||||||
|
- Lazy database initialization pattern
|
||||||
|
- Context management infrastructure
|
||||||
|
- Token counting utilities using `tiktoken`
|
||||||
|
- Context summarization at 80% token threshold
|
||||||
|
- XML-tagged context prompt building for agent injection
|
||||||
|
- REST API for multi-turn conversations
|
||||||
|
- `POST /conversations/` - Create new conversation
|
||||||
|
- `GET /conversations/` - List conversations
|
||||||
|
- `GET /conversations/{id}` - Get conversation with history
|
||||||
|
- `POST /conversations/{id}/messages` - Add message (triggers agent)
|
||||||
|
- `DELETE /conversations/{id}` - Delete conversation
|
||||||
|
- New dependencies: `sqlalchemy[asyncio]~=2.0.36`, `aiosqlite~=0.21.0`, `tiktoken>=0.12.0`
|
||||||
|
- Config settings: `database_url`, `summarization_threshold`, `keep_recent_messages`
|
||||||
|
- 19 conversation tests, 6 token counting tests (176 total tests passing)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~80% complete
|
||||||
|
- Quieter pytest output (`-q --tb=short` instead of `-v`)
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Task Agent - Full orchestrator for autonomous multi-step task execution
|
||||||
|
- Has ALL tools: read, write, edit, bash (full), web_search
|
||||||
|
- New `spawn_agent` tool to launch sub-agents (Explore, Plan) for focused work
|
||||||
|
- Recursion prevention: cannot spawn nested Task agents
|
||||||
|
- 22 unit tests for registration, tools, spawn_agent, and API
|
||||||
|
- Complete agent hierarchy: Explore (read-only) → Plan (read-only) → Task (orchestrator)
|
||||||
|
|
||||||
|
## [0.3.3] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Plan Agent - READ-ONLY software architect that designs implementation strategies
|
||||||
|
- Uses only read-only tools: `read_file`, `glob_files`, `grep_content`, `bash_readonly`
|
||||||
|
- Creates step-by-step implementation plans with critical files list
|
||||||
|
- 15 unit tests for registration, tools, and API
|
||||||
|
- Web search summarizer added to roadmap (future feature)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated COVERAGE.md to ~70% complete
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Mandatory release procedure documentation in AGENTS.md
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CI/CD pipeline credentials configured
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Reference prompts from claude-code-system-prompts for all agent types
|
||||||
|
- Detailed documentation for Explore, Plan, and Task agents
|
||||||
|
- Detailed documentation for File, Shell, and Search tools
|
||||||
|
- Utility prompts (TodoWrite, AskUserQuestion, conversation summarization, etc.)
|
||||||
|
- Security review prompt for code analysis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Expanded agents/README.md with capabilities and use cases
|
||||||
|
- Expanded tools/README.md with parameter details and behaviors
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial FastAPI boilerplate setup
|
||||||
|
- Domain-based project structure (src/domains/, src/shared/)
|
||||||
|
- BaseController pattern with lazy router instantiation
|
||||||
|
- Pydantic Settings configuration with env file support
|
||||||
|
- Logger decorator with temporal benchmarking and trace IDs
|
||||||
|
- UserProvider singleton for request-scoped context
|
||||||
|
- Custom exception hierarchy
|
||||||
|
- Health endpoints (/, /health)
|
||||||
|
- Placeholder domains for agents (explore, plan, task)
|
||||||
|
- Placeholder domains for tools (file, shell, search)
|
||||||
|
- Placeholder domain for auth (tatlock integration)
|
||||||
|
- CI/CD workflow for Gitea with Docker build and Watchtower deployment
|
||||||
|
- Dockerfile for containerized deployment
|
||||||
|
- CVE-checked dependencies (2026-01-09)
|
||||||
+67
-19
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
> Tracking progress towards Claude Code-like functionality
|
> Tracking progress towards Claude Code-like functionality
|
||||||
|
|
||||||
## Current Status: ~65% Complete
|
## Current Status: ~85% Complete
|
||||||
|
|
||||||
Last updated: 2026-01-11
|
Last updated: 2026-01-14
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,6 +44,20 @@ Last updated: 2026-01-11
|
|||||||
|
|
||||||
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
**Gap:** Mistral Nemo sometimes hallucinates instead of using tool results.
|
||||||
|
|
||||||
|
### Phase 2b: Plan Agent ✅ Complete
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `PlanAgentImpl` | ✅ | READ-ONLY software architect agent |
|
||||||
|
| System prompts | ✅ | Architecture-focused with tool examples |
|
||||||
|
| Tool registration | ✅ | Only read-only tools (4 tools) |
|
||||||
|
| Streaming support | ✅ | `run_stream()` method with SSE |
|
||||||
|
| Unit tests | ✅ | 15 tests for registration, tools, API |
|
||||||
|
|
||||||
|
**Available tools:** `read_file`, `glob_files`, `grep_content`, `bash_readonly` (read-only only)
|
||||||
|
|
||||||
|
**Purpose:** Design implementation strategies before coding - explores codebase and creates step-by-step plans.
|
||||||
|
|
||||||
### Phase 3: CLI Foundation ✅ Complete
|
### Phase 3: CLI Foundation ✅ Complete
|
||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
@@ -54,16 +68,19 @@ Last updated: 2026-01-11
|
|||||||
| Markdown rendering | ✅ | Rich markdown output |
|
| Markdown rendering | ✅ | Rich markdown output |
|
||||||
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
| Streaming display | ✅ | Real-time token output with `--stream` flag |
|
||||||
|
|
||||||
### Phase 4: Agentic Loop ⚠️ Partial
|
### Phase 4: Agentic Loop ✅ Complete
|
||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
|-----------|--------|-------|
|
|-----------|--------|-------|
|
||||||
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
| `webber-cli chat` command | ✅ | Interactive mode with streaming |
|
||||||
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
| `webber-cli explore` command | ✅ | One-shot query with streaming |
|
||||||
| `SessionState` dataclass | ✅ | Basic context tracking |
|
| `SessionState` dataclass | ✅ | Basic context tracking |
|
||||||
| `AgenticLoop` class | ⚠️ | Basic implementation, not fully utilized |
|
| `AgenticLoop` class | ✅ | Basic implementation |
|
||||||
| Conversation history | ❌ | Not persisted between turns in CLI |
|
| Conversation persistence | ✅ | SQLAlchemy async with SQLite/PostgreSQL |
|
||||||
| Context management | ❌ | No token counting or summarization |
|
| Context summarization | ✅ | Token counting (litellm) + auto-summarization |
|
||||||
|
| Conversation API | ✅ | `/conversations/` REST endpoints |
|
||||||
|
|
||||||
|
**Database:** SQLite (dev) or PostgreSQL (prod), async via SQLAlchemy 2.0
|
||||||
|
|
||||||
### Phase 5: REST API ✅ Complete
|
### Phase 5: REST API ✅ Complete
|
||||||
|
|
||||||
@@ -94,21 +111,24 @@ Last updated: 2026-01-11
|
|||||||
|
|
||||||
| Feature | Category | Description | Complexity |
|
| Feature | Category | Description | Complexity |
|
||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
| **Plan Agent** | Agents | Design implementation approaches | High |
|
| ~~**Plan Agent**~~ | Agents | ✅ Design implementation approaches | High |
|
||||||
| **Task Agent** | Agents | Autonomous multi-step execution | High |
|
| ~~**Task Agent**~~ | Agents | ✅ Autonomous multi-step execution | High |
|
||||||
| **Context summarization** | Infrastructure | Compress history at token limit | High |
|
| ~~**Context summarization**~~ | Infrastructure | ✅ Token counting + auto-summarization | High |
|
||||||
| **Conversation persistence** | CLI | Multi-turn memory in chat mode | Medium |
|
| ~~**Conversation persistence**~~ | Infrastructure | ✅ SQLAlchemy async database layer | Medium |
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
| Feature | Category | Description | Complexity |
|
| Feature | Category | Description | Complexity |
|
||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
|
| **Web search summarizer** | Tools | Agent to extract core content from web pages (remove nav, footers, etc.) and preserve relevant links for nested fetching | Medium |
|
||||||
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
|
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
|
||||||
| **Session persistence** | CLI | Save/resume conversations | Medium |
|
| ~~**Session persistence**~~ | CLI | ✅ Save/resume conversations via `sessions` and `chat --resume` | Medium |
|
||||||
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
|
||||||
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
| **Git integration** | CLI | Auto-commit, branch management | Medium |
|
||||||
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
|
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
|
||||||
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low |
|
| ~~**Retry logic**~~ | Infrastructure | ✅ Auto-retry with exponential backoff | Low |
|
||||||
|
| ~~**Permission modes**~~ | CLI | ✅ default/plan/auto_accept modes controlling tool access | Medium |
|
||||||
|
| ~~**CLI shell features**~~ | CLI | ✅ prompt_toolkit: history, tab completion, auto-suggest | Low |
|
||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
|
|
||||||
@@ -116,7 +136,7 @@ Last updated: 2026-01-11
|
|||||||
|---------|----------|-------------|------------|
|
|---------|----------|-------------|------------|
|
||||||
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
|
||||||
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
| **MCP support** | Infrastructure | Model Context Protocol | High |
|
||||||
| **Config file** | CLI | `~/.webber/config.toml` | Low |
|
| ~~**Config file**~~ | CLI | ✅ `~/.webber/config.toml` with `config` command | Low |
|
||||||
| **IDE integration** | CLI | VS Code extension | High |
|
| **IDE integration** | CLI | VS Code extension | High |
|
||||||
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
|
||||||
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
| **Agent memory** | Orchestration | Shared context between agents | Medium |
|
||||||
@@ -129,10 +149,17 @@ Last updated: 2026-01-11
|
|||||||
|------|---------|--------|--------|
|
|------|---------|--------|--------|
|
||||||
| Tool unit tests | 109 | 109 | ✅ |
|
| Tool unit tests | 109 | 109 | ✅ |
|
||||||
| API tests | 11 | 11 | ✅ |
|
| API tests | 11 | 11 | ✅ |
|
||||||
|
| Plan agent tests | 15 | 15 | ✅ |
|
||||||
|
| Task agent tests | 15 | 15 | ✅ |
|
||||||
|
| Conversation tests | 22 | 22 | ✅ |
|
||||||
|
| Token tests | 6 | 6 | ✅ |
|
||||||
|
| Retry tests | 29 | 29 | ✅ |
|
||||||
| Security tests | 14 | 14 | ✅ |
|
| Security tests | 14 | 14 | ✅ |
|
||||||
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
|
||||||
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
| E2E tests | 12 | 12 | ✅ Full API workflow |
|
||||||
|
|
||||||
|
**Total: 208 tests passing**
|
||||||
|
|
||||||
**Test breakdown:**
|
**Test breakdown:**
|
||||||
- Read/Glob/Grep tools: 17 tests
|
- Read/Glob/Grep tools: 17 tests
|
||||||
- Edit/Write tools: 22 tests
|
- Edit/Write tools: 22 tests
|
||||||
@@ -140,6 +167,11 @@ Last updated: 2026-01-11
|
|||||||
- Web search: 10 tests
|
- Web search: 10 tests
|
||||||
- Gitignore filtering: 10 tests
|
- Gitignore filtering: 10 tests
|
||||||
- API endpoints: 11 tests
|
- API endpoints: 11 tests
|
||||||
|
- Plan agent: 15 tests
|
||||||
|
- Task agent: 15 tests
|
||||||
|
- Conversations: 22 tests
|
||||||
|
- Tokens: 6 tests
|
||||||
|
- Retry: 29 tests
|
||||||
- Security: 14 tests
|
- Security: 14 tests
|
||||||
- Health checks: 2 tests
|
- Health checks: 2 tests
|
||||||
- Integration (LLM): 10 tests
|
- Integration (LLM): 10 tests
|
||||||
@@ -166,9 +198,9 @@ pytest tests/ --run-integration --run-e2e
|
|||||||
|
|
||||||
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using actual tool results.
|
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.
|
2. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
||||||
|
|
||||||
3. **Temperature setting** - Changed from 0.0 to 0.3 for Mistral Nemo compatibility, may affect determinism.
|
3. **SQLAlchemy deprecation** - `datetime.utcnow()` deprecation warning from SQLAlchemy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,9 +226,9 @@ cd webber-api && ./wakeup.sh
|
|||||||
|
|
||||||
# CLI commands (from webber-cli/)
|
# CLI commands (from webber-cli/)
|
||||||
.venv/bin/webber-cli status # Check API connection
|
.venv/bin/webber-cli status # Check API connection
|
||||||
.venv/bin/webber-cli explore "find tests" # One-shot exploration
|
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
|
||||||
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
|
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
|
||||||
.venv/bin/webber-cli chat # Interactive mode
|
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
curl http://localhost:8095/health
|
curl http://localhost:8095/health
|
||||||
@@ -205,10 +237,26 @@ curl -X POST http://localhost:8095/agents/run \
|
|||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}'
|
-d '{"agent_type":"explore","prompt":"list python files","working_dir":"."}'
|
||||||
|
|
||||||
|
# Plan agent (read-only, creates implementation plans)
|
||||||
|
curl -X POST http://localhost:8095/agents/run \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agent_type":"plan","prompt":"plan how to add user auth","working_dir":"."}'
|
||||||
|
|
||||||
# Streaming endpoint
|
# Streaming endpoint
|
||||||
curl -N http://localhost:8095/agents/stream \
|
curl -N http://localhost:8095/agents/stream \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
-d '{"agent_type":"explore","prompt":"find config files","working_dir":"."}'
|
||||||
|
|
||||||
|
# Conversation API (stateful multi-turn)
|
||||||
|
curl -X POST http://localhost:8095/conversations/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: dev-key" \
|
||||||
|
-d '{"agent_type":"explore","working_dir":"."}'
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8095/conversations/{id}/messages \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-API-Key: dev-key" \
|
||||||
|
-d '{"content":"find all Python files"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -24,18 +24,30 @@ webber/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── main.py # App entry point (NO routes)
|
│ ├── main.py # App entry point (NO routes)
|
||||||
│ │
|
│ │
|
||||||
|
│ ├── db/ # Database layer
|
||||||
|
│ │ ├── __init__.py # Exports: Database, get_database, get_session
|
||||||
|
│ │ ├── database.py # SQLAlchemy async engine, session factory
|
||||||
|
│ │ └── models.py # Base declarative model
|
||||||
|
│ │
|
||||||
│ ├── shared/ # Cross-cutting concerns
|
│ ├── shared/ # Cross-cutting concerns
|
||||||
│ │ ├── base.py # BaseController, BaseSchema
|
│ │ ├── base.py # BaseController, BaseSchema
|
||||||
│ │ ├── config.py # Pydantic Settings
|
│ │ ├── config.py # Pydantic Settings
|
||||||
│ │ ├── logging.py # @logged decorator, trace_span
|
│ │ ├── logging.py # @logged decorator, trace_span
|
||||||
│ │ ├── exceptions.py # Custom exception hierarchy
|
│ │ ├── exceptions.py # Custom exception hierarchy
|
||||||
│ │ ├── auth.py # API key validation
|
│ │ ├── auth.py # API key validation
|
||||||
│ │ └── context.py # UserProvider singleton
|
│ │ ├── context.py # UserProvider singleton
|
||||||
|
│ │ └── tokens.py # Token counting utilities (litellm)
|
||||||
│ │
|
│ │
|
||||||
│ └── domains/ # Feature domains
|
│ └── domains/ # Feature domains
|
||||||
│ ├── router.py # Root router (composes all)
|
│ ├── router.py # Root router (composes all)
|
||||||
│ ├── health/ # Health endpoints
|
│ ├── health/ # Health endpoints
|
||||||
│ ├── auth/ # Authentication
|
│ ├── auth/ # Authentication
|
||||||
|
│ ├── conversations/ # Multi-turn conversation memory
|
||||||
|
│ │ ├── models.py # Conversation, Message SQLAlchemy models
|
||||||
|
│ │ ├── schemas.py # Pydantic request/response models
|
||||||
|
│ │ ├── service.py # ConversationService business logic
|
||||||
|
│ │ ├── router.py # REST API endpoints
|
||||||
|
│ │ └── summarize.py # Context summarization logic
|
||||||
│ ├── agents/ # Agent orchestration
|
│ ├── agents/ # Agent orchestration
|
||||||
│ │ ├── explore/ # Codebase navigation
|
│ │ ├── explore/ # Codebase navigation
|
||||||
│ │ ├── plan/ # Implementation design
|
│ │ ├── plan/ # Implementation design
|
||||||
@@ -195,12 +207,21 @@ All settings via environment variables or `.env`:
|
|||||||
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
| OLLAMA_URL | http://192.168.86.149:11434 | Ollama API URL |
|
||||||
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
|
| OLLAMA_AGENT_MODEL | mistral-nemo-large:latest | Agent reasoning model |
|
||||||
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
| OLLAMA_EMBED_MODEL | nomic-embed-text:latest | Embedding model |
|
||||||
| TATLOCK_API_URL | http://192.168.86.149:8000 | Tatlock auth service |
|
| TATLOCK_API_URL | http://tatlock:8000 | Tatlock auth service |
|
||||||
|
| SEARXNG_URL | http://searxng:8080 | SearXNG web search instance |
|
||||||
|
| SEARXNG_TIMEOUT | 10 | SearXNG request timeout (seconds) |
|
||||||
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
| TOOL_TIMEOUT_SECONDS | 120 | Tool execution timeout |
|
||||||
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
| SANDBOX_ENABLED | true | Enable sandboxed execution |
|
||||||
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
| ALLOWED_PATHS | [] | Paths accessible to tools |
|
||||||
| SESSION_TTL_HOURS | 24 | Session expiry |
|
| SESSION_TTL_HOURS | 24 | Session expiry |
|
||||||
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
| MAX_CONTEXT_TOKENS | 128000 | Max context window |
|
||||||
|
| DATABASE_URL | sqlite+aiosqlite:///./webber.db | Database connection URL |
|
||||||
|
| SUMMARIZATION_THRESHOLD | 0.8 | Summarize at N% of max tokens |
|
||||||
|
| SUMMARIZATION_TARGET_TOKENS | 500 | Target summary size |
|
||||||
|
| KEEP_RECENT_MESSAGES | 6 | Messages to keep unsummarized |
|
||||||
|
| RETRY_MAX_ATTEMPTS | 3 | Max retry attempts for transient failures |
|
||||||
|
| RETRY_BASE_DELAY | 1.0 | Base delay between retries (seconds) |
|
||||||
|
| RETRY_MAX_DELAY | 30.0 | Maximum delay between retries (seconds) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -250,6 +271,53 @@ Tools are sandboxed operations agents can invoke:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Database Layer
|
||||||
|
|
||||||
|
SQLAlchemy 2.0 async with lazy initialization pattern.
|
||||||
|
|
||||||
|
### Supported Databases
|
||||||
|
- **Development**: SQLite via `aiosqlite`
|
||||||
|
- **Production**: PostgreSQL via `asyncpg`
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
```python
|
||||||
|
from src.db import get_session
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def my_endpoint(session: AsyncSession = Depends(get_session)):
|
||||||
|
# Session auto-commits on success, rollbacks on exception
|
||||||
|
result = await session.execute(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables are created lazily on first `get_session()` call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conversation API
|
||||||
|
|
||||||
|
Multi-turn conversation memory with automatic context summarization.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/conversations/` | POST | Create new conversation |
|
||||||
|
| `/conversations/` | GET | List user's conversations |
|
||||||
|
| `/conversations/{id}` | GET | Get conversation with history |
|
||||||
|
| `/conversations/{id}/messages` | POST | Add message, triggers agent |
|
||||||
|
| `/conversations/{id}` | DELETE | Delete conversation |
|
||||||
|
|
||||||
|
### Models
|
||||||
|
- **Conversation**: User session with agent type, working directory
|
||||||
|
- **Message**: Individual messages with role, content, token count
|
||||||
|
|
||||||
|
### Context Summarization
|
||||||
|
When total tokens exceed 80% of `MAX_CONTEXT_TOKENS`:
|
||||||
|
1. Keep last 6 messages intact
|
||||||
|
2. Summarize older messages into a single summary message
|
||||||
|
3. Mark old messages as summarized (soft delete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
1. Client sends `X-API-Key` header
|
1. Client sends `X-API-Key` header
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "webber-api"
|
name = "webber-api"
|
||||||
version = "0.3.2"
|
version = "1.0.1"
|
||||||
description = "Webber API - Multi-Agent AI Development Server"
|
description = "Webber API - Multi-Agent AI Development Server"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jpmschweitzer"}
|
{name = "jpmschweitzer"}
|
||||||
@@ -27,7 +27,7 @@ include = ["src*"]
|
|||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
python_functions = ["test_*"]
|
python_functions = ["test_*"]
|
||||||
addopts = "-v --strict-markers"
|
addopts = "-q --strict-markers --tb=short"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as integration tests (require Ollama to be running)",
|
"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)",
|
"e2e: marks tests as end-to-end tests (require API server to be running)",
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ rich~=13.9.0
|
|||||||
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
|
pathspec~=0.12.1 # Gitignore pattern matching
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy[asyncio]~=2.0.36
|
||||||
|
aiosqlite~=0.21.0 # SQLite async driver (dev)
|
||||||
|
|
||||||
|
# Token counting
|
||||||
|
tiktoken>=0.12.0 # OpenAI tokenizer (used for estimation)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Database package for Webber.
|
||||||
|
|
||||||
|
Provides async SQLAlchemy database access following core-api patterns.
|
||||||
|
"""
|
||||||
|
from src.db.database import Database, get_database, get_session
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Database",
|
||||||
|
"get_database",
|
||||||
|
"get_session",
|
||||||
|
"Base",
|
||||||
|
]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Async SQLAlchemy database management.
|
||||||
|
|
||||||
|
Pattern from core-api: singleton Database class with async session factory.
|
||||||
|
"""
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""
|
||||||
|
Async database connection manager.
|
||||||
|
|
||||||
|
Manages SQLAlchemy async engine and session factory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str):
|
||||||
|
"""
|
||||||
|
Initialize database with connection URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: SQLAlchemy async connection URL
|
||||||
|
e.g., "sqlite+aiosqlite:///./webber.db"
|
||||||
|
or "postgresql+asyncpg://user:pass@host/db"
|
||||||
|
"""
|
||||||
|
self._url = url
|
||||||
|
self._engine: AsyncEngine | None = None
|
||||||
|
self._session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engine(self) -> AsyncEngine:
|
||||||
|
"""Get or create the async engine."""
|
||||||
|
if self._engine is None:
|
||||||
|
self._engine = create_async_engine(
|
||||||
|
self._url,
|
||||||
|
echo=get_settings().debug,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
return self._engine
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Get or create the session factory."""
|
||||||
|
if self._session_factory is None:
|
||||||
|
self._session_factory = async_sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
return self._session_factory
|
||||||
|
|
||||||
|
async def create_tables(self) -> None:
|
||||||
|
"""Create all tables (for development)."""
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables created")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the database connection."""
|
||||||
|
if self._engine:
|
||||||
|
await self._engine.dispose()
|
||||||
|
self._engine = None
|
||||||
|
self._session_factory = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_database: Database | None = None
|
||||||
|
_tables_created: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""Get the database singleton."""
|
||||||
|
global _database
|
||||||
|
if _database is None:
|
||||||
|
settings = get_settings()
|
||||||
|
_database = Database(settings.database_url)
|
||||||
|
return _database
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_tables() -> None:
|
||||||
|
"""Ensure database tables exist (lazy initialization)."""
|
||||||
|
global _tables_created
|
||||||
|
if not _tables_created:
|
||||||
|
database = get_database()
|
||||||
|
await database.create_tables()
|
||||||
|
_tables_created = True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""
|
||||||
|
Dependency for getting async database sessions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@router.get("/")
|
||||||
|
async def endpoint(session: AsyncSession = Depends(get_session)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
await _ensure_tables()
|
||||||
|
database = get_database()
|
||||||
|
async with database.session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy Base model for all database models.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all SQLAlchemy models."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""
|
||||||
|
Tool approval evaluation logic.
|
||||||
|
|
||||||
|
Provides granular control over tool execution:
|
||||||
|
- Rule-based matching on tool name and arguments
|
||||||
|
- Priority-ordered rule evaluation
|
||||||
|
- Default fallback behavior
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.domains.agents.schemas import (
|
||||||
|
ApprovalAction,
|
||||||
|
ApprovalRule,
|
||||||
|
ApprovalRuleSet,
|
||||||
|
PermissionMode,
|
||||||
|
)
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_tool_args(tool_args: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Serialize tool arguments to a string for pattern matching.
|
||||||
|
|
||||||
|
Converts tool args dict to a consistent string format that can be
|
||||||
|
matched against regex patterns.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
{"command": "curl localhost:8095"} -> "command=curl localhost:8095"
|
||||||
|
{"file_path": "/src/main.py"} -> "file_path=/src/main.py"
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for key, value in sorted(tool_args.items()):
|
||||||
|
parts.append(f"{key}={value}")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_rule(rule: ApprovalRule, tool_name: str, tool_args: dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a rule matches the given tool call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rule: The approval rule to evaluate
|
||||||
|
tool_name: Name of the tool being called
|
||||||
|
tool_args: Arguments passed to the tool
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the rule matches, False otherwise
|
||||||
|
"""
|
||||||
|
# Tool name must match exactly
|
||||||
|
if rule.tool != tool_name and rule.tool != "*":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Serialize args for pattern matching
|
||||||
|
args_str = _serialize_tool_args(tool_args)
|
||||||
|
|
||||||
|
# Try to match pattern against serialized args
|
||||||
|
try:
|
||||||
|
if re.search(rule.pattern, args_str, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
except re.error as e:
|
||||||
|
logger.warning(f"Invalid regex pattern in rule: {rule.pattern} - {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_approval(
|
||||||
|
ruleset: ApprovalRuleSet,
|
||||||
|
tool_name: str,
|
||||||
|
tool_args: dict[str, Any],
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
) -> ApprovalAction:
|
||||||
|
"""
|
||||||
|
Evaluate whether a tool call should be allowed, denied, or prompt for approval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ruleset: Set of approval rules to evaluate
|
||||||
|
tool_name: Name of the tool being called
|
||||||
|
tool_args: Arguments passed to the tool
|
||||||
|
mode: Current permission mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ApprovalAction indicating what to do (allow, deny, ask)
|
||||||
|
"""
|
||||||
|
# Plan mode: only read-only tools are even registered, so if we get here
|
||||||
|
# it's a read-only tool and should be allowed
|
||||||
|
if mode == PermissionMode.plan:
|
||||||
|
return ApprovalAction.allow
|
||||||
|
|
||||||
|
# Auto-accept mode: allow everything without prompting
|
||||||
|
if mode == PermissionMode.auto_accept:
|
||||||
|
return ApprovalAction.allow
|
||||||
|
|
||||||
|
# Default mode: evaluate rules
|
||||||
|
# Sort rules by priority (highest first)
|
||||||
|
sorted_rules = sorted(ruleset.rules, key=lambda r: r.priority, reverse=True)
|
||||||
|
|
||||||
|
for rule in sorted_rules:
|
||||||
|
if evaluate_rule(rule, tool_name, tool_args):
|
||||||
|
logger.debug(
|
||||||
|
f"Rule matched: {rule.description or rule.pattern} -> {rule.action}"
|
||||||
|
)
|
||||||
|
return rule.action
|
||||||
|
|
||||||
|
# No rules matched, use default action
|
||||||
|
return ruleset.default_action
|
||||||
|
|
||||||
|
|
||||||
|
# === Default rule sets ===
|
||||||
|
|
||||||
|
# Read-only tools that never need approval
|
||||||
|
READONLY_TOOLS = {"read_file", "glob_files", "grep_content", "bash_readonly"}
|
||||||
|
|
||||||
|
# Default rules for common patterns
|
||||||
|
DEFAULT_RULES = ApprovalRuleSet(
|
||||||
|
rules=[
|
||||||
|
# Always allow read-only tools
|
||||||
|
ApprovalRule(
|
||||||
|
tool="read_file",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all file reads",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="glob_files",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all glob searches",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="grep_content",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all grep searches",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash_readonly",
|
||||||
|
pattern=".*",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow all read-only bash commands",
|
||||||
|
priority=100,
|
||||||
|
),
|
||||||
|
# Dangerous patterns - always deny
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="rm\\s+-rf\\s+/",
|
||||||
|
action=ApprovalAction.deny,
|
||||||
|
description="Deny recursive delete from root",
|
||||||
|
priority=90,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="sudo\\s+",
|
||||||
|
action=ApprovalAction.deny,
|
||||||
|
description="Deny sudo commands",
|
||||||
|
priority=90,
|
||||||
|
),
|
||||||
|
# Common safe patterns - allow without prompting
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=git\\s+(status|log|diff|show|branch)",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow read-only git commands",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=pytest\\s+",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow pytest execution",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=python\\s+-m\\s+pytest",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow pytest via python -m",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=curl.*localhost",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow curl to localhost",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
ApprovalRule(
|
||||||
|
tool="bash",
|
||||||
|
pattern="command=curl.*127\\.0\\.0\\.1",
|
||||||
|
action=ApprovalAction.allow,
|
||||||
|
description="Allow curl to 127.0.0.1",
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default_action=ApprovalAction.ask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_ruleset() -> ApprovalRuleSet:
|
||||||
|
"""Get the default approval ruleset."""
|
||||||
|
return DEFAULT_RULES
|
||||||
|
|
||||||
|
|
||||||
|
def is_readonly_tool(tool_name: str) -> bool:
|
||||||
|
"""Check if a tool is read-only (never needs approval)."""
|
||||||
|
return tool_name in READONLY_TOOLS
|
||||||
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
from src.domains.agents.explore.tools import register_explore_tools
|
from src.domains.agents.explore.tools import register_explore_tools
|
||||||
register_explore_tools(agent)
|
register_explore_tools(agent)
|
||||||
|
|
||||||
|
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||||
|
"""Build the prompt with working directory context."""
|
||||||
|
return f"""Working directory: {working_dir}
|
||||||
|
|
||||||
|
Use paths within this working directory for file operations.
|
||||||
|
|
||||||
|
User request: {prompt}"""
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
Returns:
|
Returns:
|
||||||
Agent response with findings
|
Agent response with findings
|
||||||
"""
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
ctx = ExploreContext(
|
ctx = ExploreContext(
|
||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=effective_working_dir,
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
|
||||||
async with trace_span("explore_agent_run"):
|
async with trace_span("explore_agent_run"):
|
||||||
try:
|
try:
|
||||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||||
result = await self.agent.run(prompt, deps=ctx)
|
result = await self.agent.run(full_prompt, deps=ctx)
|
||||||
return result.output
|
return result.output
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Explore agent error: {e}")
|
logger.exception(f"Explore agent error: {e}")
|
||||||
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
|
|||||||
|
|
||||||
Yields text chunks as they become available.
|
Yields text chunks as they become available.
|
||||||
"""
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
ctx = ExploreContext(
|
ctx = ExploreContext(
|
||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=effective_working_dir,
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
|
||||||
async with trace_span("explore_agent_stream"):
|
async with trace_span("explore_agent_stream"):
|
||||||
try:
|
try:
|
||||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||||
async for chunk in result.stream_text():
|
async for chunk in result.stream_text():
|
||||||
yield chunk
|
yield chunk
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Plan Agent - Software architect for implementation planning.
|
||||||
|
|
||||||
|
The Plan agent explores codebases and designs step-by-step implementation
|
||||||
|
strategies. It uses only read-only tools and cannot modify any files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.domains.agents.plan import plan_agent, plan
|
||||||
|
|
||||||
|
# Direct agent access
|
||||||
|
result = await plan_agent.run("Plan how to add user authentication")
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
result = await plan("Plan how to add user authentication")
|
||||||
|
"""
|
||||||
|
from src.domains.agents.plan.agent import (
|
||||||
|
PlanAgentImpl,
|
||||||
|
PlanContext,
|
||||||
|
plan_agent,
|
||||||
|
plan,
|
||||||
|
plan_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PlanAgentImpl",
|
||||||
|
"PlanContext",
|
||||||
|
"plan_agent",
|
||||||
|
"plan",
|
||||||
|
"plan_stream",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Plan Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
Software architect agent that explores codebases and designs implementation plans.
|
||||||
|
Uses only read-only tools - cannot modify any files.
|
||||||
|
"""
|
||||||
|
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.plan.prompts import PLAN_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 PlanContext(AgentContext):
|
||||||
|
"""
|
||||||
|
Context for plan agent tools.
|
||||||
|
|
||||||
|
Passed to all tool functions via RunContext.
|
||||||
|
Uses the same fields as base AgentContext.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlanAgentImpl(BaseAgent):
|
||||||
|
"""
|
||||||
|
Software architect agent for implementation planning.
|
||||||
|
|
||||||
|
Explores codebases to understand patterns and conventions,
|
||||||
|
then designs step-by-step implementation plans.
|
||||||
|
|
||||||
|
READ-ONLY: Cannot modify files - uses only exploration tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "plan"
|
||||||
|
description = "Software architect for designing implementation plans - explores codebase and creates step-by-step strategies"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the plan agent."""
|
||||||
|
self._agent: Agent[PlanContext, str] | None = None
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
def _create_agent(self) -> Agent[PlanContext, 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[PlanContext, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=PLAN_SYSTEM_PROMPT,
|
||||||
|
deps_type=PlanContext,
|
||||||
|
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 read-only tools
|
||||||
|
self._register_tools(agent)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _register_tools(self, agent: Agent[PlanContext, str]) -> None:
|
||||||
|
"""Register read-only exploration tools with the agent."""
|
||||||
|
from src.domains.agents.plan.tools import register_plan_tools
|
||||||
|
register_plan_tools(agent)
|
||||||
|
|
||||||
|
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||||
|
"""Build the prompt with working directory context."""
|
||||||
|
return f"""Working directory: {working_dir}
|
||||||
|
|
||||||
|
Use paths within this working directory for file operations.
|
||||||
|
|
||||||
|
User request: {prompt}"""
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run the plan agent to design an implementation strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Description of what to implement
|
||||||
|
working_dir: Working directory for exploration
|
||||||
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Implementation plan with steps and critical files
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
ctx = PlanContext(
|
||||||
|
working_dir=effective_working_dir,
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
|
||||||
|
async with trace_span("plan_agent_run"):
|
||||||
|
try:
|
||||||
|
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||||
|
result = await self.agent.run(full_prompt, deps=ctx)
|
||||||
|
return result.output
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Plan 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 plan agent with streaming output.
|
||||||
|
|
||||||
|
Yields text chunks as they become available.
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
ctx = PlanContext(
|
||||||
|
working_dir=effective_working_dir,
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
|
||||||
|
async with trace_span("plan_agent_stream"):
|
||||||
|
try:
|
||||||
|
async with self.agent.run_stream(full_prompt, deps=ctx) as result:
|
||||||
|
async for chunk in result.stream_text():
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Plan agent stream error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Create and register the singleton instance
|
||||||
|
plan_agent = PlanAgentImpl()
|
||||||
|
register_agent(plan_agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def plan(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Run planning query."""
|
||||||
|
return await plan_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Run planning query with streaming."""
|
||||||
|
async for chunk in plan_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield chunk
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
System prompts for the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent is a READ-ONLY software architect that explores codebases
|
||||||
|
and designs implementation plans without modifying any files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLAN_SYSTEM_PROMPT = """You are a software architect and planning specialist.
|
||||||
|
|
||||||
|
Your role is to explore codebases and design implementation plans.
|
||||||
|
|
||||||
|
CRITICAL: You are READ-ONLY. You CANNOT modify any files.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- read_file: Read file contents
|
||||||
|
- grep_content: Search code with regex
|
||||||
|
- bash_readonly: Run read-only commands (ls, git status, git log, etc.)
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
1. Understand the requirements
|
||||||
|
2. Explore the codebase to find relevant patterns and conventions
|
||||||
|
3. Design an implementation approach
|
||||||
|
4. Create a step-by-step plan with specific files and changes
|
||||||
|
|
||||||
|
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 patterns:
|
||||||
|
Call grep_content with pattern="class.*Controller"
|
||||||
|
|
||||||
|
To check git history:
|
||||||
|
Call bash_readonly with command="git log --oneline -10"
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
End your response with:
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
1. [First step with specific file and changes]
|
||||||
|
2. [Second step...]
|
||||||
|
3. [Continue...]
|
||||||
|
|
||||||
|
### Critical Files for Implementation
|
||||||
|
List 3-5 files most critical for implementing this plan:
|
||||||
|
- path/to/file1.py - [Brief reason: e.g., "Core logic to modify"]
|
||||||
|
- path/to/file2.py - [Brief reason: e.g., "Pattern to follow"]
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS use tools first, then analyze results
|
||||||
|
- Follow existing patterns in the codebase
|
||||||
|
- Consider trade-offs and alternatives
|
||||||
|
- Identify dependencies and sequencing
|
||||||
|
- Never guess - verify with tools
|
||||||
|
- Provide specific file paths and code locations
|
||||||
|
"""
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent only has access to READ-ONLY tools.
|
||||||
|
It cannot modify files - only explore and analyze.
|
||||||
|
"""
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from src.domains.tools.file.read import ReadFileTool
|
||||||
|
from src.domains.tools.file.glob import GlobFilesTool
|
||||||
|
from src.domains.tools.search.grep import GrepContentTool
|
||||||
|
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||||
|
|
||||||
|
|
||||||
|
def register_plan_tools(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register read-only exploration tools with the Plan agent.
|
||||||
|
|
||||||
|
The Plan agent is restricted to read-only tools:
|
||||||
|
- read_file: Read file contents
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents
|
||||||
|
- bash_readonly: Read-only shell commands
|
||||||
|
|
||||||
|
Write tools (edit_file, write_file, bash) are NOT available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@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. Use this to understand existing code.
|
||||||
|
"""
|
||||||
|
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 find code patterns and implementations.
|
||||||
|
"""
|
||||||
|
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()
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
REST API routes for agents.
|
REST API routes for agents.
|
||||||
|
|
||||||
|
Supports permission modes for controlling agent tool access:
|
||||||
|
- default: All tools available (approval may be required)
|
||||||
|
- plan: Read-only tools only
|
||||||
|
- auto_accept: All tools, no approval prompts
|
||||||
|
|
||||||
|
Streaming uses structured events instead of raw text to avoid
|
||||||
|
garbled output during tool execution.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -9,12 +17,23 @@ from src.domains.agents.base import get_agent, list_agents
|
|||||||
|
|
||||||
# Import agents to ensure they're registered
|
# Import agents to ensure they're registered
|
||||||
import src.domains.agents.explore # noqa: F401
|
import src.domains.agents.explore # noqa: F401
|
||||||
|
import src.domains.agents.plan # noqa: F401
|
||||||
|
import src.domains.agents.task # noqa: F401
|
||||||
from src.domains.agents.schemas import (
|
from src.domains.agents.schemas import (
|
||||||
AgentRunRequest,
|
AgentRunRequest,
|
||||||
AgentRunResponse,
|
AgentRunResponse,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
PermissionMode,
|
||||||
|
StreamEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mode(mode_value: str | PermissionMode) -> PermissionMode:
|
||||||
|
"""Convert mode string to enum (handles use_enum_values=True)."""
|
||||||
|
if isinstance(mode_value, PermissionMode):
|
||||||
|
return mode_value
|
||||||
|
return PermissionMode(mode_value)
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import logged, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -38,6 +57,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
The agent will use tools to explore the codebase and answer questions.
|
The agent will use tools to explore the codebase and answer questions.
|
||||||
|
Permission mode controls which tools are available.
|
||||||
"""
|
"""
|
||||||
# Get the requested agent
|
# Get the requested agent
|
||||||
agent = get_agent(request.agent_type)
|
agent = get_agent(request.agent_type)
|
||||||
@@ -47,16 +67,21 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
detail=f"Unknown agent type: {request.agent_type}"
|
detail=f"Unknown agent type: {request.agent_type}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Convert mode string to enum (use_enum_values=True in schema)
|
||||||
|
mode = _get_mode(request.mode)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the agent
|
# Run the agent with mode
|
||||||
response = await agent.run(
|
response = await agent.run(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response=response,
|
response=response,
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode, # Keep original for response
|
||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,6 +90,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response="",
|
response="",
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode,
|
||||||
success=False,
|
success=False,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
@@ -76,11 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
"""
|
"""
|
||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Returns Server-Sent Events (SSE) with text chunks.
|
Returns Server-Sent Events (SSE) with structured events.
|
||||||
Event types:
|
Permission mode controls which tools are available.
|
||||||
- "chunk": Text chunk from the agent
|
|
||||||
- "done": Stream complete
|
Event types (from StreamEvent):
|
||||||
- "error": Error occurred
|
- tool_start: Tool execution beginning
|
||||||
|
- tool_done: Tool execution complete
|
||||||
|
- thinking: Agent status update
|
||||||
|
- response: Final response text chunk
|
||||||
|
- error: Error occurred
|
||||||
|
- done: Stream complete
|
||||||
"""
|
"""
|
||||||
agent = get_agent(request.agent_type)
|
agent = get_agent(request.agent_type)
|
||||||
if not agent:
|
if not agent:
|
||||||
@@ -89,22 +120,28 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
detail=f"Unknown agent type: {request.agent_type}"
|
detail=f"Unknown agent type: {request.agent_type}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Convert mode string to enum (use_enum_values=True in schema)
|
||||||
|
mode = _get_mode(request.mode)
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
try:
|
try:
|
||||||
async for chunk in agent.run_stream(
|
async for event in agent.run_stream(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=mode,
|
||||||
):
|
):
|
||||||
# SSE format: data: {json}\n\n
|
# Handle both StreamEvent objects and legacy string chunks
|
||||||
event = {"event": "chunk", "data": chunk}
|
if isinstance(event, StreamEvent):
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
# New structured event format
|
||||||
|
event_data = event.model_dump(exclude_none=True)
|
||||||
# Signal completion
|
yield f"data: {json.dumps(event_data)}\n\n"
|
||||||
yield f"data: {json.dumps({'event': 'done'})}\n\n"
|
else:
|
||||||
|
# Legacy string chunk (for explore/plan agents)
|
||||||
|
yield f"data: {json.dumps({'event': 'chunk', 'data': event})}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Stream error: {e}")
|
logger.exception(f"Stream error: {e}")
|
||||||
error_event = {"event": "error", "data": str(e)}
|
error_event = {"event": "error", "error_message": str(e)}
|
||||||
yield f"data: {json.dumps(error_event)}\n\n"
|
yield f"data: {json.dumps(error_event)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -1,22 +1,121 @@
|
|||||||
"""
|
"""
|
||||||
Request and response schemas for agent API.
|
Request and response schemas for agent API.
|
||||||
"""
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes that control agent tool access.
|
||||||
|
|
||||||
|
Aligns with Claude Code's permission model:
|
||||||
|
- default: Full tools, approval required for writes (future)
|
||||||
|
- plan: Read-only tools only, no approval needed
|
||||||
|
- auto_accept: Full tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
auto_accept = "auto_accept"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(str, Enum):
|
||||||
|
"""Status of a tool approval request."""
|
||||||
|
pending = "pending"
|
||||||
|
approved = "approved"
|
||||||
|
denied = "denied"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalAction(str, Enum):
|
||||||
|
"""Action to take when a rule matches."""
|
||||||
|
allow = "allow" # Auto-approve without prompting
|
||||||
|
deny = "deny" # Auto-deny without prompting
|
||||||
|
ask = "ask" # Prompt user for approval
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRule(BaseSchema):
|
||||||
|
"""
|
||||||
|
Granular approval rule for tool execution.
|
||||||
|
|
||||||
|
Allows fine-grained control over which tool calls are allowed:
|
||||||
|
- Pattern matching on tool arguments
|
||||||
|
- Different actions per rule (allow, deny, ask)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Allow curl to localhost
|
||||||
|
ApprovalRule(tool="bash", pattern="curl.*localhost.*", action="allow")
|
||||||
|
|
||||||
|
# Deny any rm command
|
||||||
|
ApprovalRule(tool="bash", pattern="rm\\s+.*", action="deny")
|
||||||
|
|
||||||
|
# Ask for git push
|
||||||
|
ApprovalRule(tool="bash", pattern="git\\s+push.*", action="ask")
|
||||||
|
|
||||||
|
# Allow all file reads in src/
|
||||||
|
ApprovalRule(tool="read_file", pattern=".*/src/.*", action="allow")
|
||||||
|
"""
|
||||||
|
tool: str # Tool name to match (e.g., "bash", "edit_file")
|
||||||
|
pattern: str # Regex pattern to match against tool args
|
||||||
|
action: ApprovalAction # What to do when matched
|
||||||
|
description: str | None = None # Human-readable description of rule
|
||||||
|
priority: int = 0 # Higher priority rules evaluated first
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRuleSet(BaseSchema):
|
||||||
|
"""
|
||||||
|
Collection of approval rules with evaluation logic.
|
||||||
|
|
||||||
|
Rules are evaluated in priority order (highest first).
|
||||||
|
First matching rule determines the action.
|
||||||
|
If no rules match, falls back to default action.
|
||||||
|
"""
|
||||||
|
rules: list[ApprovalRule] = []
|
||||||
|
default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
Request for tool execution approval.
|
||||||
|
|
||||||
|
Sent from API to CLI when a tool needs user approval.
|
||||||
|
Prep for future bidirectional approval flow.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
tool_name: str
|
||||||
|
tool_args: dict
|
||||||
|
description: str
|
||||||
|
risk_level: str = "write" # "read", "write", "dangerous"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalResponse(BaseSchema):
|
||||||
|
"""
|
||||||
|
Response to a tool approval request.
|
||||||
|
|
||||||
|
Sent from CLI to API with user's decision.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
status: ApprovalStatus
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRunRequest(BaseSchema):
|
class AgentRunRequest(BaseSchema):
|
||||||
"""Request to run an agent."""
|
"""Request to run an agent."""
|
||||||
prompt: str
|
prompt: str
|
||||||
working_dir: str = "."
|
working_dir: str = "."
|
||||||
agent_type: str = "explore"
|
agent_type: str = "task" # Default to task agent (main agent)
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
|
|
||||||
|
|
||||||
class AgentRunResponse(BaseSchema):
|
class AgentRunResponse(BaseSchema):
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
success: bool = True
|
success: bool = True
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
# Prep for approval flow - if set, CLI should handle approval
|
||||||
|
pending_approval: ToolApprovalRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentInfo(BaseSchema):
|
class AgentInfo(BaseSchema):
|
||||||
@@ -28,3 +127,36 @@ class AgentInfo(BaseSchema):
|
|||||||
class AgentListResponse(BaseSchema):
|
class AgentListResponse(BaseSchema):
|
||||||
"""List of available agents."""
|
"""List of available agents."""
|
||||||
agents: list[AgentInfo]
|
agents: list[AgentInfo]
|
||||||
|
|
||||||
|
|
||||||
|
# Streaming event types for event-based streaming
|
||||||
|
class StreamEventType(str, Enum):
|
||||||
|
"""
|
||||||
|
Event types for structured agent streaming.
|
||||||
|
|
||||||
|
Instead of streaming raw text (which gets garbled during tool calls),
|
||||||
|
we emit structured events that the CLI can render appropriately.
|
||||||
|
"""
|
||||||
|
tool_start = "tool_start" # Tool execution starting
|
||||||
|
tool_done = "tool_done" # Tool execution complete
|
||||||
|
thinking = "thinking" # Agent reasoning status
|
||||||
|
response = "response" # Final response text chunk
|
||||||
|
error = "error" # Error occurred
|
||||||
|
done = "done" # Stream complete
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEvent(BaseSchema):
|
||||||
|
"""
|
||||||
|
Structured streaming event from agent execution.
|
||||||
|
|
||||||
|
Events are emitted instead of raw text to provide clean
|
||||||
|
progress feedback during multi-tool agent loops.
|
||||||
|
"""
|
||||||
|
event: StreamEventType
|
||||||
|
tool: str | None = None # Tool name (for tool_start/tool_done)
|
||||||
|
args: dict | None = None # Tool arguments (for tool_start)
|
||||||
|
result_summary: str | None = None # Brief result (for tool_done)
|
||||||
|
message: str | None = None # Status message (for thinking)
|
||||||
|
text: str | None = None # Response text (for response)
|
||||||
|
error_message: str | None = None # Error details (for error)
|
||||||
|
mode: str | None = None # Permission mode (for done)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Task Agent - Full orchestrator for autonomous task execution.
|
||||||
|
|
||||||
|
The Task agent can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write + bash)
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
- Return consolidated task summaries
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from src.domains.agents.task import task_agent, task
|
||||||
|
|
||||||
|
# Direct agent access
|
||||||
|
result = await task_agent.run("Create a new user model with tests")
|
||||||
|
|
||||||
|
# Convenience function
|
||||||
|
result = await task("Create a new user model with tests")
|
||||||
|
"""
|
||||||
|
from src.domains.agents.task.agent import (
|
||||||
|
TaskAgentImpl,
|
||||||
|
TaskContext,
|
||||||
|
task_agent,
|
||||||
|
task,
|
||||||
|
task_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TaskAgentImpl",
|
||||||
|
"TaskContext",
|
||||||
|
"task_agent",
|
||||||
|
"task",
|
||||||
|
"task_stream",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""
|
||||||
|
Task Agent implementation using PydanticAI.
|
||||||
|
|
||||||
|
Full orchestrator agent that can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write) based on permission mode
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
- Stream structured events instead of raw text
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
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.schemas import PermissionMode, StreamEvent, StreamEventType
|
||||||
|
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_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 TaskContext(AgentContext):
|
||||||
|
"""
|
||||||
|
Context for task agent tools.
|
||||||
|
|
||||||
|
Passed to all tool functions via RunContext.
|
||||||
|
Extends base AgentContext with permission mode and event queue.
|
||||||
|
"""
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
|
# Prep for approval flow - tools can check this
|
||||||
|
pending_approvals: list[str] = field(default_factory=list)
|
||||||
|
# Event queue for streaming events from tools
|
||||||
|
event_queue: asyncio.Queue | None = field(default=None, repr=False)
|
||||||
|
# Track tool calls for retry logic
|
||||||
|
tools_called: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||||
|
"""Emit an event to the queue if available."""
|
||||||
|
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||||
|
ctx.event_queue.put_nowait(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||||
|
"""Create a brief summary of a tool result."""
|
||||||
|
# Count lines if multiline
|
||||||
|
lines = result.strip().split('\n')
|
||||||
|
if len(lines) > 1:
|
||||||
|
return f"{len(lines)} lines"
|
||||||
|
# Single line - truncate if needed
|
||||||
|
if len(result) > max_len:
|
||||||
|
return result[:max_len] + "..."
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAgentImpl(BaseAgent):
|
||||||
|
"""
|
||||||
|
Full orchestrator agent for autonomous task execution.
|
||||||
|
|
||||||
|
Tool access depends on permission mode:
|
||||||
|
- plan: Read-only tools only (safe exploration)
|
||||||
|
- default: All tools (approval required for writes - future)
|
||||||
|
- auto_accept: All tools (no approval prompts)
|
||||||
|
|
||||||
|
Can spawn Explore and Plan agents to offload focused tasks,
|
||||||
|
keeping context efficient across complex multi-step work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "task"
|
||||||
|
description = "Autonomous multi-step task execution with sub-agent orchestration"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the task agent."""
|
||||||
|
# Cache agents by mode to avoid recreating
|
||||||
|
self._agents: dict[PermissionMode, Agent[TaskContext, str]] = {}
|
||||||
|
self._settings = get_settings()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agent(self) -> Agent[TaskContext, str]:
|
||||||
|
"""Default agent (full mode) for compatibility."""
|
||||||
|
return self._get_agent_for_mode(PermissionMode.default)
|
||||||
|
|
||||||
|
def _get_agent_for_mode(self, mode: PermissionMode) -> Agent[TaskContext, str]:
|
||||||
|
"""Get or create agent configured for the specified mode."""
|
||||||
|
if mode not in self._agents:
|
||||||
|
self._agents[mode] = self._create_agent(mode)
|
||||||
|
return self._agents[mode]
|
||||||
|
|
||||||
|
def _create_agent(self, mode: PermissionMode = PermissionMode.default) -> Agent[TaskContext, 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select system prompt based on mode
|
||||||
|
system_prompt = TASK_PLAN_MODE_PROMPT if mode == PermissionMode.plan else TASK_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
agent: Agent[TaskContext, str] = Agent(
|
||||||
|
model=model,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
deps_type=TaskContext,
|
||||||
|
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 based on mode
|
||||||
|
self._register_tools(agent, mode)
|
||||||
|
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
|
||||||
|
"""Register tools with the agent based on permission mode."""
|
||||||
|
from src.domains.agents.task.tools_streaming import (
|
||||||
|
register_task_tools_streaming,
|
||||||
|
register_readonly_tools_streaming,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == PermissionMode.plan:
|
||||||
|
# Plan mode: read-only tools only
|
||||||
|
register_readonly_tools_streaming(agent)
|
||||||
|
else:
|
||||||
|
# Default and auto_accept: all tools
|
||||||
|
register_task_tools_streaming(agent)
|
||||||
|
|
||||||
|
# Maximum retries when no tools are called
|
||||||
|
MAX_NO_TOOL_RETRIES = 2
|
||||||
|
|
||||||
|
def _build_prompt_with_context(self, prompt: str, working_dir: str) -> str:
|
||||||
|
"""Build the prompt with working directory context."""
|
||||||
|
return f"""Working directory: {working_dir}
|
||||||
|
|
||||||
|
When using file tools, use paths relative to or within this working directory.
|
||||||
|
For example, to read a file at {working_dir}/README.md, use file_path="{working_dir}/README.md".
|
||||||
|
|
||||||
|
User request: {prompt}"""
|
||||||
|
|
||||||
|
def _build_retry_prompt(self, prompt: str, working_dir: str) -> str:
|
||||||
|
"""Build a stronger prompt for retry after no tool calls."""
|
||||||
|
return f"""Working directory: {working_dir}
|
||||||
|
|
||||||
|
IMPORTANT: Your previous response was REJECTED because you did not call any tools.
|
||||||
|
You MUST call a tool (like glob_files, bash_readonly, or read_file) BEFORE responding.
|
||||||
|
DO NOT answer from memory. DO NOT fabricate information.
|
||||||
|
|
||||||
|
Call a tool NOW to gather real information, then respond based on the results.
|
||||||
|
|
||||||
|
User request: {prompt}"""
|
||||||
|
|
||||||
|
@logged()
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Run the task agent to execute a multi-step task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Description of the task to execute
|
||||||
|
working_dir: Working directory for the agent
|
||||||
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Consolidated task summary with results
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
# Get agent configured for this mode
|
||||||
|
agent = self._get_agent_for_mode(mode)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_run"):
|
||||||
|
retries = 0
|
||||||
|
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||||
|
# Create fresh context for each attempt
|
||||||
|
ctx = TaskContext(
|
||||||
|
working_dir=effective_working_dir,
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build prompt - use retry prompt if this is a retry
|
||||||
|
if retries == 0:
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
else:
|
||||||
|
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||||
|
logger.warning(f"Retry {retries}/{self.MAX_NO_TOOL_RETRIES}: No tools called, retrying with stronger prompt")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await agent.run(full_prompt, deps=ctx)
|
||||||
|
|
||||||
|
# Check if tools were called
|
||||||
|
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||||
|
retries += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ctx.tools_called == 0:
|
||||||
|
logger.warning("Agent responded without calling tools after all retries")
|
||||||
|
|
||||||
|
return result.output
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Task agent error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Should not reach here, but just in case
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
async def run_stream(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
|
"""
|
||||||
|
Run the task agent with structured event streaming.
|
||||||
|
|
||||||
|
Instead of streaming raw text (which gets garbled during tool calls),
|
||||||
|
yields structured events that clients can render appropriately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: Task description
|
||||||
|
working_dir: Working directory
|
||||||
|
allowed_paths: Restrict tool access
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
StreamEvent objects for tool progress and final response.
|
||||||
|
|
||||||
|
Event types:
|
||||||
|
- tool_start: Tool execution beginning
|
||||||
|
- tool_done: Tool execution complete with summary
|
||||||
|
- thinking: Agent status update
|
||||||
|
- response: Final response text
|
||||||
|
- error: Error occurred
|
||||||
|
- done: Stream complete
|
||||||
|
"""
|
||||||
|
effective_working_dir = working_dir or os.getcwd()
|
||||||
|
|
||||||
|
# Get agent configured for this mode
|
||||||
|
agent = self._get_agent_for_mode(mode)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_stream"):
|
||||||
|
# Emit initial thinking event
|
||||||
|
yield StreamEvent(
|
||||||
|
event=StreamEventType.thinking,
|
||||||
|
message="Starting task execution..."
|
||||||
|
)
|
||||||
|
|
||||||
|
retries = 0
|
||||||
|
response = ""
|
||||||
|
|
||||||
|
while retries <= self.MAX_NO_TOOL_RETRIES:
|
||||||
|
# Create fresh event queue and context for each attempt
|
||||||
|
event_queue: asyncio.Queue[StreamEvent] = asyncio.Queue()
|
||||||
|
|
||||||
|
ctx = TaskContext(
|
||||||
|
working_dir=effective_working_dir,
|
||||||
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
mode=mode,
|
||||||
|
event_queue=event_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build prompt - use retry prompt if this is a retry
|
||||||
|
if retries == 0:
|
||||||
|
full_prompt = self._build_prompt_with_context(prompt, effective_working_dir)
|
||||||
|
else:
|
||||||
|
full_prompt = self._build_retry_prompt(prompt, effective_working_dir)
|
||||||
|
yield StreamEvent(
|
||||||
|
event=StreamEventType.thinking,
|
||||||
|
message=f"Retrying (attempt {retries + 1})..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run agent in background task so we can yield events
|
||||||
|
async def run_agent() -> str:
|
||||||
|
try:
|
||||||
|
result = await agent.run(full_prompt, deps=ctx)
|
||||||
|
return result.output
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Task agent stream error: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
agent_task = asyncio.create_task(run_agent())
|
||||||
|
|
||||||
|
# Yield events from queue while agent runs
|
||||||
|
try:
|
||||||
|
while not agent_task.done():
|
||||||
|
try:
|
||||||
|
# Check for events with timeout
|
||||||
|
event = await asyncio.wait_for(
|
||||||
|
event_queue.get(),
|
||||||
|
timeout=0.1
|
||||||
|
)
|
||||||
|
yield event
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# No events, check if agent is done
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Drain remaining events
|
||||||
|
while not event_queue.empty():
|
||||||
|
yield event_queue.get_nowait()
|
||||||
|
|
||||||
|
# Get final result
|
||||||
|
response = await agent_task
|
||||||
|
|
||||||
|
# Check if tools were called - if not, retry
|
||||||
|
if ctx.tools_called == 0 and retries < self.MAX_NO_TOOL_RETRIES:
|
||||||
|
logger.warning(f"No tools called, retrying ({retries + 1}/{self.MAX_NO_TOOL_RETRIES})")
|
||||||
|
retries += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ctx.tools_called == 0:
|
||||||
|
logger.warning("Agent responded without calling tools after all retries")
|
||||||
|
|
||||||
|
# Success - break out of retry loop
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Stream error: {e}")
|
||||||
|
yield StreamEvent(
|
||||||
|
event=StreamEventType.error,
|
||||||
|
error_message=str(e)
|
||||||
|
)
|
||||||
|
# Cancel agent if still running
|
||||||
|
if not agent_task.done():
|
||||||
|
agent_task.cancel()
|
||||||
|
try:
|
||||||
|
await agent_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# Yield response in chunks for streaming feel
|
||||||
|
chunk_size = 100
|
||||||
|
for i in range(0, len(response), chunk_size):
|
||||||
|
chunk = response[i:i + chunk_size]
|
||||||
|
yield StreamEvent(
|
||||||
|
event=StreamEventType.response,
|
||||||
|
text=chunk
|
||||||
|
)
|
||||||
|
# Small delay for streaming effect
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
# Signal completion
|
||||||
|
yield StreamEvent(
|
||||||
|
event=StreamEventType.done,
|
||||||
|
mode=mode.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Create and register the singleton instance
|
||||||
|
task_agent = TaskAgentImpl()
|
||||||
|
register_agent(task_agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def task(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> str:
|
||||||
|
"""Run task execution."""
|
||||||
|
return await task_agent.run(prompt, working_dir=working_dir, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def task_stream(
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None,
|
||||||
|
**kwargs: Any
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
|
"""Run task execution with event streaming."""
|
||||||
|
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
|
||||||
|
yield event
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
System prompts for the Task agent.
|
||||||
|
|
||||||
|
The Task agent is a full orchestrator that can:
|
||||||
|
- Execute multi-step tasks autonomously
|
||||||
|
- Use all tools (read + write) based on permission mode
|
||||||
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
|
||||||
|
|
||||||
|
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||||
|
- NEVER answer from memory or assumptions
|
||||||
|
- NEVER fabricate file structures, code, or content
|
||||||
|
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||||
|
|
||||||
|
You can explore and analyze code but CANNOT modify files or execute write operations.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS (read-only):
|
||||||
|
|
||||||
|
File Operations:
|
||||||
|
- read_file: Read file contents with line numbers
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents with regex
|
||||||
|
|
||||||
|
Shell:
|
||||||
|
- bash_readonly: Read-only commands (ls, git status, git log, git diff, etc.)
|
||||||
|
|
||||||
|
Orchestration:
|
||||||
|
- spawn_agent: Launch sub-agents for focused tasks (explore, plan only)
|
||||||
|
|
||||||
|
MANDATORY WORKFLOW:
|
||||||
|
1. FIRST: Call a tool to gather real information
|
||||||
|
2. THEN: Analyze the actual tool results
|
||||||
|
3. FINALLY: Respond based only on what tools returned
|
||||||
|
|
||||||
|
TOOL CALL EXAMPLES:
|
||||||
|
|
||||||
|
To find all Python files:
|
||||||
|
Call glob_files with pattern="**/*.py"
|
||||||
|
|
||||||
|
To search for a function:
|
||||||
|
Call grep_content with pattern="def my_function"
|
||||||
|
|
||||||
|
To check git status:
|
||||||
|
Call bash_readonly with command="git status"
|
||||||
|
|
||||||
|
To list directory contents:
|
||||||
|
Call bash_readonly with command="ls -la"
|
||||||
|
|
||||||
|
To get deeper analysis:
|
||||||
|
Call spawn_agent with agent_type="explore" and prompt="find authentication code"
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS call a tool FIRST - no exceptions
|
||||||
|
- Never guess or fabricate - only report what tools return
|
||||||
|
- Be thorough in exploration
|
||||||
|
- Provide specific file paths and line numbers from tool results
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Structure your response with:
|
||||||
|
|
||||||
|
### Analysis
|
||||||
|
- What was found (from tool results)
|
||||||
|
- Key patterns identified
|
||||||
|
- Relevant files (actual paths from tools)
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
- Suggested approach
|
||||||
|
- Potential concerns
|
||||||
|
- Next steps (to be executed in full mode)
|
||||||
|
"""
|
||||||
|
|
||||||
|
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||||
|
|
||||||
|
CRITICAL RULE: You MUST call a tool BEFORE responding to ANY request.
|
||||||
|
- NEVER answer from memory or assumptions
|
||||||
|
- NEVER fabricate file structures, code, or content
|
||||||
|
- If you respond without calling a tool first, YOUR ANSWER IS WRONG
|
||||||
|
|
||||||
|
You have access to ALL tools including file editing, writing, and bash execution.
|
||||||
|
You can also spawn sub-agents to help with complex tasks.
|
||||||
|
|
||||||
|
AVAILABLE TOOLS:
|
||||||
|
|
||||||
|
File Operations:
|
||||||
|
- read_file: Read file contents with line numbers
|
||||||
|
- glob_files: Find files by pattern
|
||||||
|
- grep_content: Search file contents with regex
|
||||||
|
- edit_file: Make targeted edits via find-and-replace
|
||||||
|
- write_file: Create or overwrite files
|
||||||
|
|
||||||
|
Shell:
|
||||||
|
- bash_readonly: Read-only commands (ls, git status, git log, etc.)
|
||||||
|
- bash: Full bash execution (git commit, pytest, mkdir, etc.)
|
||||||
|
|
||||||
|
External:
|
||||||
|
- web_search: Search the web for current information
|
||||||
|
|
||||||
|
Orchestration:
|
||||||
|
- spawn_agent: Launch sub-agents for focused tasks
|
||||||
|
|
||||||
|
MANDATORY WORKFLOW:
|
||||||
|
1. FIRST: Call a tool to gather real information
|
||||||
|
2. THEN: Analyze the actual tool results
|
||||||
|
3. Execute implementation using write tools if needed
|
||||||
|
4. Validate changes (run tests if applicable)
|
||||||
|
5. FINALLY: Return summary based only on what tools returned
|
||||||
|
|
||||||
|
TOOL CALL EXAMPLES:
|
||||||
|
|
||||||
|
To list directory contents:
|
||||||
|
Call bash_readonly with command="ls -la"
|
||||||
|
|
||||||
|
To find all Python files:
|
||||||
|
Call glob_files with pattern="**/*.py"
|
||||||
|
|
||||||
|
To spawn an Explore agent for research:
|
||||||
|
Call spawn_agent with agent_type="explore" and prompt="find all config files"
|
||||||
|
|
||||||
|
To spawn a Plan agent for design:
|
||||||
|
Call spawn_agent with agent_type="plan" and prompt="design user auth feature"
|
||||||
|
|
||||||
|
To edit a file:
|
||||||
|
Call edit_file with file_path="/path/to/file.py" and old_string="old" and new_string="new"
|
||||||
|
|
||||||
|
To run tests:
|
||||||
|
Call bash with command="pytest tests/ -v"
|
||||||
|
|
||||||
|
SPAWN_AGENT USAGE:
|
||||||
|
- Use spawn_agent to offload focused tasks to specialized agents
|
||||||
|
- Explore agent: Fast codebase searches and analysis
|
||||||
|
- Plan agent: Design implementation strategies
|
||||||
|
- Keep each agent's context focused and efficient
|
||||||
|
|
||||||
|
GIT DISCIPLINE:
|
||||||
|
- Create feature branches for changes
|
||||||
|
- Use conventional commit format (feat:, fix:, docs:, etc.)
|
||||||
|
- Never commit directly to main
|
||||||
|
- Run tests before committing
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS call a tool FIRST - no exceptions
|
||||||
|
- Never guess or fabricate - only report what tools return
|
||||||
|
- Prefer edit_file over write_file for existing files
|
||||||
|
- Use spawn_agent to keep context focused
|
||||||
|
- Validate changes by running tests when applicable
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
End your response with a summary:
|
||||||
|
|
||||||
|
### Task Summary
|
||||||
|
- **Accomplished:** What was done
|
||||||
|
- **Files modified:** List of changed files
|
||||||
|
- **Commands run:** Key commands executed
|
||||||
|
- **Issues:** Any problems encountered
|
||||||
|
"""
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Task agent.
|
||||||
|
|
||||||
|
The Task agent has access to tools based on permission mode:
|
||||||
|
- Plan mode: Read-only tools only
|
||||||
|
- Default/auto_accept: All tools including write operations
|
||||||
|
"""
|
||||||
|
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_read_file(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register read_file tool."""
|
||||||
|
@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. Read files before editing them.
|
||||||
|
"""
|
||||||
|
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register glob_files tool."""
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_grep_content(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register grep_content tool."""
|
||||||
|
@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"
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register bash_readonly tool."""
|
||||||
|
@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)
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_spawn_agent(agent: Agent[AgentContext, str], readonly_only: bool = False) -> None:
|
||||||
|
"""Register spawn_agent tool."""
|
||||||
|
@agent.tool
|
||||||
|
async def spawn_agent(
|
||||||
|
ctx: RunContext[AgentContext],
|
||||||
|
agent_type: str,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Spawn a sub-agent to handle a focused task.
|
||||||
|
|
||||||
|
Use this to offload work to specialized agents:
|
||||||
|
- "explore": Fast codebase searches and analysis (read-only)
|
||||||
|
- "plan": Design implementation strategies (read-only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent to spawn ("explore" or "plan")
|
||||||
|
prompt: Task description for the sub-agent
|
||||||
|
working_dir: Working directory for the sub-agent (default: current)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sub-agent's consolidated response.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- spawn_agent(agent_type="explore", prompt="find all test files")
|
||||||
|
- spawn_agent(agent_type="plan", prompt="design user auth feature")
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Use sub-agents to keep context focused and efficient
|
||||||
|
- Explore agent for research, Plan agent for design
|
||||||
|
- Cannot spawn nested Task agents (recursion risk)
|
||||||
|
"""
|
||||||
|
from src.domains.agents.base import get_agent
|
||||||
|
|
||||||
|
# Validate agent type
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
if agent_type not in allowed_types:
|
||||||
|
if agent_type == "task":
|
||||||
|
return "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||||
|
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||||
|
|
||||||
|
sub_agent = get_agent(agent_type)
|
||||||
|
if not sub_agent:
|
||||||
|
return f"Error: Agent '{agent_type}' not found in registry"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await sub_agent.run(
|
||||||
|
prompt=prompt,
|
||||||
|
working_dir=working_dir or ctx.deps.working_dir,
|
||||||
|
allowed_paths=ctx.deps.allowed_paths,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Sub-agent error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def register_readonly_tools(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register read-only tools with the agent.
|
||||||
|
|
||||||
|
Used in plan mode. Includes:
|
||||||
|
- read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- spawn_agent (restricted to explore/plan)
|
||||||
|
"""
|
||||||
|
_register_read_file(agent)
|
||||||
|
_register_glob_files(agent)
|
||||||
|
_register_grep_content(agent)
|
||||||
|
_register_bash_readonly(agent)
|
||||||
|
_register_spawn_agent(agent, readonly_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register all tools with the Task agent.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- Write tools: edit_file, write_file, bash
|
||||||
|
- External: web_search
|
||||||
|
- Orchestration: spawn_agent
|
||||||
|
"""
|
||||||
|
# Register read-only tools via helpers
|
||||||
|
_register_read_file(agent)
|
||||||
|
_register_glob_files(agent)
|
||||||
|
_register_grep_content(agent)
|
||||||
|
_register_bash_readonly(agent)
|
||||||
|
|
||||||
|
# === Write 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 bash 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)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "mkdir -p src/utils" creates directory
|
||||||
|
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||||
|
- "pytest tests/ -v" runs tests
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
|
||||||
|
# === External tools ===
|
||||||
|
|
||||||
|
@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
|
||||||
|
- Technical references with URLs
|
||||||
|
"""
|
||||||
|
tool = WebSearchTool()
|
||||||
|
result = await tool.execute(
|
||||||
|
query=query,
|
||||||
|
num_results=num_results,
|
||||||
|
categories=categories
|
||||||
|
)
|
||||||
|
return result.to_string()
|
||||||
|
|
||||||
|
# === Orchestration tools ===
|
||||||
|
_register_spawn_agent(agent, readonly_only=False)
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
"""
|
||||||
|
Tool registrations for the Task agent with event streaming.
|
||||||
|
|
||||||
|
Same tools as tools.py but emit StreamEvent events for progress tracking.
|
||||||
|
Tools push events to the context's event_queue when available.
|
||||||
|
"""
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from src.domains.agents.schemas import StreamEvent, StreamEventType
|
||||||
|
from src.domains.agents.task.agent import TaskContext
|
||||||
|
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 _emit_event(ctx: AgentContext, event: StreamEvent) -> None:
|
||||||
|
"""Emit an event to the queue if available."""
|
||||||
|
if hasattr(ctx, 'event_queue') and ctx.event_queue is not None:
|
||||||
|
ctx.event_queue.put_nowait(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _track_tool_call(ctx: AgentContext) -> None:
|
||||||
|
"""Increment tool call counter for retry logic."""
|
||||||
|
if hasattr(ctx, 'tools_called'):
|
||||||
|
ctx.tools_called += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_result(result: str, max_len: int = 80) -> str:
|
||||||
|
"""Create a brief summary of a tool result."""
|
||||||
|
lines = result.strip().split('\n')
|
||||||
|
if len(lines) > 3:
|
||||||
|
return f"{len(lines)} lines"
|
||||||
|
if len(result) > max_len:
|
||||||
|
return result[:max_len] + "..."
|
||||||
|
return result.replace('\n', ' ')
|
||||||
|
|
||||||
|
|
||||||
|
def _register_read_file(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register read_file tool with event streaming."""
|
||||||
|
@agent.tool
|
||||||
|
async def read_file(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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. Read files before editing them.
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="read_file",
|
||||||
|
args={"file_path": file_path, "offset": offset, "limit": limit}
|
||||||
|
))
|
||||||
|
|
||||||
|
tool = ReadFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="read_file",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
|
||||||
|
def _register_glob_files(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register glob_files tool with event streaming."""
|
||||||
|
@agent.tool
|
||||||
|
async def glob_files(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="glob_files",
|
||||||
|
args={"pattern": pattern, "path": path}
|
||||||
|
))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="glob_files",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
|
||||||
|
def _register_grep_content(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register grep_content tool with event streaming."""
|
||||||
|
@agent.tool
|
||||||
|
async def grep_content(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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"
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="grep_content",
|
||||||
|
args={"pattern": pattern, "path": path, "file_glob": file_glob}
|
||||||
|
))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="grep_content",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
|
||||||
|
def _register_bash_readonly(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""Register bash_readonly tool with event streaming."""
|
||||||
|
@agent.tool
|
||||||
|
async def bash_readonly(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="bash_readonly",
|
||||||
|
args={"command": command}
|
||||||
|
))
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="bash_readonly",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
|
||||||
|
def _register_spawn_agent(agent: Agent[TaskContext, str], readonly_only: bool = False) -> None:
|
||||||
|
"""Register spawn_agent tool with event streaming."""
|
||||||
|
@agent.tool
|
||||||
|
async def spawn_agent(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
agent_type: str,
|
||||||
|
prompt: str,
|
||||||
|
working_dir: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Spawn a sub-agent to handle a focused task.
|
||||||
|
|
||||||
|
Use this to offload work to specialized agents:
|
||||||
|
- "explore": Fast codebase searches and analysis (read-only)
|
||||||
|
- "plan": Design implementation strategies (read-only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent to spawn ("explore" or "plan")
|
||||||
|
prompt: Task description for the sub-agent
|
||||||
|
working_dir: Working directory for the sub-agent (default: current)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sub-agent's consolidated response.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- spawn_agent(agent_type="explore", prompt="find all test files")
|
||||||
|
- spawn_agent(agent_type="plan", prompt="design user auth feature")
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Use sub-agents to keep context focused and efficient
|
||||||
|
- Explore agent for research, Plan agent for design
|
||||||
|
- Cannot spawn nested Task agents (recursion risk)
|
||||||
|
"""
|
||||||
|
from src.domains.agents.base import get_agent
|
||||||
|
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="spawn_agent",
|
||||||
|
args={"agent_type": agent_type, "prompt": prompt[:50] + "..." if len(prompt) > 50 else prompt}
|
||||||
|
))
|
||||||
|
|
||||||
|
# Validate agent type
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
if agent_type not in allowed_types:
|
||||||
|
if agent_type == "task":
|
||||||
|
result = "Error: Cannot spawn nested Task agents (recursion risk)"
|
||||||
|
else:
|
||||||
|
result = f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="spawn_agent",
|
||||||
|
result_summary=result
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
sub_agent = get_agent(agent_type)
|
||||||
|
if not sub_agent:
|
||||||
|
result = f"Error: Agent '{agent_type}' not found in registry"
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="spawn_agent",
|
||||||
|
result_summary=result
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await sub_agent.run(
|
||||||
|
prompt=prompt,
|
||||||
|
working_dir=working_dir or ctx.deps.working_dir,
|
||||||
|
allowed_paths=ctx.deps.allowed_paths,
|
||||||
|
)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="spawn_agent",
|
||||||
|
result_summary=_summarize_result(result)
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
result = f"Sub-agent error: {e}"
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="spawn_agent",
|
||||||
|
result_summary=result
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def register_readonly_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register read-only tools with event streaming.
|
||||||
|
|
||||||
|
Used in plan mode. Includes:
|
||||||
|
- read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- spawn_agent (restricted to explore/plan)
|
||||||
|
"""
|
||||||
|
_register_read_file(agent)
|
||||||
|
_register_glob_files(agent)
|
||||||
|
_register_grep_content(agent)
|
||||||
|
_register_bash_readonly(agent)
|
||||||
|
_register_spawn_agent(agent, readonly_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def register_task_tools_streaming(agent: Agent[TaskContext, str]) -> None:
|
||||||
|
"""
|
||||||
|
Register all tools with event streaming.
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
|
||||||
|
- Write tools: edit_file, write_file, bash
|
||||||
|
- External: web_search
|
||||||
|
- Orchestration: spawn_agent
|
||||||
|
"""
|
||||||
|
# Register read-only tools via helpers
|
||||||
|
_register_read_file(agent)
|
||||||
|
_register_glob_files(agent)
|
||||||
|
_register_grep_content(agent)
|
||||||
|
_register_bash_readonly(agent)
|
||||||
|
|
||||||
|
# === Write tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def edit_file(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="edit_file",
|
||||||
|
args={"file_path": file_path, "replace_all": replace_all}
|
||||||
|
))
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="edit_file",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def write_file(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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 bash mkdir first if needed)
|
||||||
|
- For editing existing files, prefer edit_file instead
|
||||||
|
- Will overwrite existing files without confirmation
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="write_file",
|
||||||
|
args={"file_path": file_path, "content_length": len(content)}
|
||||||
|
))
|
||||||
|
|
||||||
|
tool = WriteFileTool(allowed_paths=ctx.deps.allowed_paths)
|
||||||
|
result = await tool.execute(
|
||||||
|
file_path=file_path,
|
||||||
|
content=content
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="write_file",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def bash(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "mkdir -p src/utils" creates directory
|
||||||
|
- "git add . && git commit -m 'fix: bug'" commits changes
|
||||||
|
- "pytest tests/ -v" runs tests
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="bash",
|
||||||
|
args={"command": command}
|
||||||
|
))
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="bash",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
# === External tools ===
|
||||||
|
|
||||||
|
@agent.tool
|
||||||
|
async def web_search(
|
||||||
|
ctx: RunContext[TaskContext],
|
||||||
|
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
|
||||||
|
- Technical references with URLs
|
||||||
|
"""
|
||||||
|
_track_tool_call(ctx.deps)
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_start,
|
||||||
|
tool="web_search",
|
||||||
|
args={"query": query}
|
||||||
|
))
|
||||||
|
|
||||||
|
tool = WebSearchTool()
|
||||||
|
result = await tool.execute(
|
||||||
|
query=query,
|
||||||
|
num_results=num_results,
|
||||||
|
categories=categories
|
||||||
|
)
|
||||||
|
result_str = result.to_string()
|
||||||
|
|
||||||
|
_emit_event(ctx.deps, StreamEvent(
|
||||||
|
event=StreamEventType.tool_done,
|
||||||
|
tool="web_search",
|
||||||
|
result_summary=_summarize_result(result_str)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result_str
|
||||||
|
|
||||||
|
# === Orchestration tools ===
|
||||||
|
_register_spawn_agent(agent, readonly_only=False)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Conversations domain - Multi-turn conversation management.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Conversation persistence with message history
|
||||||
|
- Context summarization when approaching token limits
|
||||||
|
- Agent integration with conversation context injection
|
||||||
|
"""
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Conversation",
|
||||||
|
"Message",
|
||||||
|
"ConversationService",
|
||||||
|
]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Database models for conversations.
|
||||||
|
|
||||||
|
Following core-api patterns: SQLAlchemy 2.0 with async support.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.db.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(Base):
|
||||||
|
"""
|
||||||
|
A conversation session with an agent.
|
||||||
|
|
||||||
|
Tracks message history, token usage, and metadata.
|
||||||
|
"""
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
agent_type: Mapped[str] = mapped_column(String(50), default="explore", insert_default="explore")
|
||||||
|
title: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||||
|
working_dir: Mapped[str] = mapped_column(String(1024), default=".", insert_default=".")
|
||||||
|
total_tokens: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
default=datetime.utcnow,
|
||||||
|
onupdate=datetime.utcnow,
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
messages: Mapped[list["Message"]] = relationship(
|
||||||
|
back_populates="conversation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Conversation {self.id} agent={self.agent_type}>"
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
"""
|
||||||
|
A single message in a conversation.
|
||||||
|
|
||||||
|
Tracks role, content, token count, and summarization state.
|
||||||
|
"""
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
|
||||||
|
conversation_id: Mapped[UUID] = mapped_column(
|
||||||
|
ForeignKey("conversations.id", ondelete="CASCADE"),
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system, summary
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
token_count: Mapped[int] = mapped_column(default=0, insert_default=0)
|
||||||
|
is_summary: Mapped[bool] = mapped_column(default=False, insert_default=False)
|
||||||
|
summarizes_up_to: Mapped[UUID | None] = mapped_column(nullable=True, default=None)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
preview = self.content[:30] + "..." if len(self.content) > 30 else self.content
|
||||||
|
return f"<Message {self.role}: {preview}>"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
REST API routes for conversations.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db import get_session
|
||||||
|
from src.domains.conversations.schemas import (
|
||||||
|
AddMessageRequest,
|
||||||
|
AddMessageResponse,
|
||||||
|
ConversationDetailResponse,
|
||||||
|
ConversationListResponse,
|
||||||
|
ConversationResponse,
|
||||||
|
CreateConversationRequest,
|
||||||
|
MessageResponse,
|
||||||
|
SaveMessagesRequest,
|
||||||
|
SaveMessagesResponse,
|
||||||
|
)
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
from src.shared.auth import require_auth
|
||||||
|
from src.shared.logging import logged, get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/conversations", tags=["Conversations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ConversationResponse, status_code=201)
|
||||||
|
@logged()
|
||||||
|
async def create_conversation(
|
||||||
|
request: CreateConversationRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationResponse:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Starts an empty conversation with the specified agent type.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.create(
|
||||||
|
user_id=user.id,
|
||||||
|
agent_type=request.agent_type,
|
||||||
|
working_dir=request.working_dir,
|
||||||
|
title=request.title,
|
||||||
|
)
|
||||||
|
return ConversationResponse.model_validate(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=ConversationListResponse)
|
||||||
|
@logged()
|
||||||
|
async def list_conversations(
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationListResponse:
|
||||||
|
"""
|
||||||
|
List user's conversations.
|
||||||
|
|
||||||
|
Returns conversations sorted by most recently updated.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversations, total = await service.list_by_user(
|
||||||
|
user_id=user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
return ConversationListResponse(
|
||||||
|
conversations=[ConversationResponse.model_validate(c) for c in conversations],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{conversation_id}", response_model=ConversationDetailResponse)
|
||||||
|
@logged()
|
||||||
|
async def get_conversation(
|
||||||
|
conversation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> ConversationDetailResponse:
|
||||||
|
"""
|
||||||
|
Get conversation with all messages.
|
||||||
|
|
||||||
|
Returns conversation metadata and full message history.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.get_with_messages(conversation_id)
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
return ConversationDetailResponse.model_validate(conversation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{conversation_id}", status_code=204)
|
||||||
|
@logged()
|
||||||
|
async def delete_conversation(
|
||||||
|
conversation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Delete a conversation and all its messages.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
await service.delete(conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/messages", response_model=AddMessageResponse)
|
||||||
|
@logged()
|
||||||
|
async def add_message(
|
||||||
|
conversation_id: UUID,
|
||||||
|
request: AddMessageRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> AddMessageResponse:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation and get agent response.
|
||||||
|
|
||||||
|
This is the main endpoint for continuing conversations.
|
||||||
|
It:
|
||||||
|
1. Adds the user message
|
||||||
|
2. Checks if summarization is needed
|
||||||
|
3. Builds context from conversation history
|
||||||
|
4. Gets agent response
|
||||||
|
5. Adds agent response to conversation
|
||||||
|
6. Returns both messages
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
|
||||||
|
# Verify conversation exists and user owns it
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Add user message
|
||||||
|
user_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="user",
|
||||||
|
content=request.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if summarization needed before getting response
|
||||||
|
summarized = await service.summarize_if_needed(conversation_id)
|
||||||
|
|
||||||
|
# Get agent response with context
|
||||||
|
try:
|
||||||
|
response_text = await service.get_agent_response(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_message=request.content,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Agent response failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Agent error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add assistant message
|
||||||
|
assistant_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="assistant",
|
||||||
|
content=response_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated conversation for total tokens
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
return AddMessageResponse(
|
||||||
|
user_message=MessageResponse.model_validate(user_message),
|
||||||
|
assistant_message=MessageResponse.model_validate(assistant_message),
|
||||||
|
total_tokens=conversation.total_tokens if conversation else 0,
|
||||||
|
summarized=summarized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{conversation_id}/save", response_model=SaveMessagesResponse)
|
||||||
|
@logged()
|
||||||
|
async def save_messages(
|
||||||
|
conversation_id: UUID,
|
||||||
|
request: SaveMessagesRequest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
user=Depends(require_auth),
|
||||||
|
) -> SaveMessagesResponse:
|
||||||
|
"""
|
||||||
|
Save a user/assistant message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used by CLI when streaming responses separately via /agents/stream.
|
||||||
|
This allows persisting the exchange after streaming completes.
|
||||||
|
"""
|
||||||
|
service = ConversationService(session)
|
||||||
|
|
||||||
|
# Verify conversation exists and user owns it
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
if conversation.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
|
# Save user message
|
||||||
|
user_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="user",
|
||||||
|
content=request.user_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save assistant message
|
||||||
|
assistant_message = await service.add_message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="assistant",
|
||||||
|
content=request.assistant_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get updated conversation for total tokens
|
||||||
|
conversation = await service.get(conversation_id)
|
||||||
|
|
||||||
|
return SaveMessagesResponse(
|
||||||
|
user_message=MessageResponse.model_validate(user_message),
|
||||||
|
assistant_message=MessageResponse.model_validate(assistant_message),
|
||||||
|
total_tokens=conversation.total_tokens if conversation else 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Pydantic schemas for conversation API.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
# === Request Schemas ===
|
||||||
|
|
||||||
|
class CreateConversationRequest(BaseModel):
|
||||||
|
"""Request to create a new conversation."""
|
||||||
|
agent_type: str = Field(default="explore", description="Agent type to use")
|
||||||
|
working_dir: str = Field(default=".", description="Working directory for agent")
|
||||||
|
title: str | None = Field(default=None, description="Optional conversation title")
|
||||||
|
|
||||||
|
|
||||||
|
class AddMessageRequest(BaseModel):
|
||||||
|
"""Request to add a message to a conversation."""
|
||||||
|
content: str = Field(..., min_length=1, description="Message content")
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMessagesRequest(BaseModel):
|
||||||
|
"""Request to save a message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used by CLI when streaming responses separately via /agents/stream.
|
||||||
|
"""
|
||||||
|
user_content: str = Field(..., min_length=1, description="User message content")
|
||||||
|
assistant_content: str = Field(..., min_length=1, description="Assistant response content")
|
||||||
|
|
||||||
|
|
||||||
|
# === Response Schemas ===
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""Response for a single message."""
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
token_count: int
|
||||||
|
is_summary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationResponse(BaseModel):
|
||||||
|
"""Response for conversation metadata."""
|
||||||
|
id: UUID
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationDetailResponse(BaseModel):
|
||||||
|
"""Response for conversation with messages."""
|
||||||
|
id: UUID
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
messages: list[MessageResponse]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListResponse(BaseModel):
|
||||||
|
"""Response for listing conversations."""
|
||||||
|
conversations: list[ConversationResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AddMessageResponse(BaseModel):
|
||||||
|
"""Response after adding a message (includes agent response)."""
|
||||||
|
user_message: MessageResponse
|
||||||
|
assistant_message: MessageResponse
|
||||||
|
total_tokens: int
|
||||||
|
summarized: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether context was summarized due to token limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMessagesResponse(BaseModel):
|
||||||
|
"""Response after saving messages (no agent execution)."""
|
||||||
|
user_message: MessageResponse
|
||||||
|
assistant_message: MessageResponse
|
||||||
|
total_tokens: int
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Conversation service - Business logic for conversation management.
|
||||||
|
|
||||||
|
Handles CRUD operations, context building, and summarization triggers.
|
||||||
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.summarize import generate_summary
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
from src.shared.tokens import count_tokens
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationService:
|
||||||
|
"""
|
||||||
|
Service for managing conversations and messages.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- CRUD operations for conversations and messages
|
||||||
|
- Context building for agent prompts
|
||||||
|
- Automatic summarization when approaching token limits
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize with database session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Async SQLAlchemy session
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
self.settings = get_settings()
|
||||||
|
|
||||||
|
# === Conversation CRUD ===
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
agent_type: str = "explore",
|
||||||
|
working_dir: str = ".",
|
||||||
|
title: str | None = None,
|
||||||
|
) -> Conversation:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's user ID
|
||||||
|
agent_type: Type of agent for this conversation
|
||||||
|
working_dir: Working directory for agent
|
||||||
|
title: Optional title (auto-generated from first message if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Conversation object
|
||||||
|
"""
|
||||||
|
conversation = Conversation(
|
||||||
|
user_id=user_id,
|
||||||
|
agent_type=agent_type,
|
||||||
|
working_dir=working_dir,
|
||||||
|
title=title,
|
||||||
|
)
|
||||||
|
self.session.add(conversation)
|
||||||
|
await self.session.flush()
|
||||||
|
logger.info(f"Created conversation {conversation.id} for user {user_id}")
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
async def get(self, conversation_id: UUID) -> Conversation | None:
|
||||||
|
"""Get conversation by ID without messages."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation).where(Conversation.id == conversation_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_with_messages(self, conversation_id: UUID) -> Conversation | None:
|
||||||
|
"""Get conversation by ID with messages loaded."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.options(selectinload(Conversation.messages))
|
||||||
|
.where(Conversation.id == conversation_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Conversation], int]:
|
||||||
|
"""
|
||||||
|
List conversations for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
limit: Maximum results to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (conversations, total_count)
|
||||||
|
"""
|
||||||
|
# Get total count
|
||||||
|
count_result = await self.session.execute(
|
||||||
|
select(func.count(Conversation.id))
|
||||||
|
.where(Conversation.user_id == user_id)
|
||||||
|
)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get conversations
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Conversation)
|
||||||
|
.where(Conversation.user_id == user_id)
|
||||||
|
.order_by(Conversation.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
conversations = list(result.scalars().all())
|
||||||
|
|
||||||
|
return conversations, total
|
||||||
|
|
||||||
|
async def delete(self, conversation_id: UUID) -> bool:
|
||||||
|
"""Delete a conversation and all its messages."""
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if conversation:
|
||||||
|
await self.session.delete(conversation)
|
||||||
|
logger.info(f"Deleted conversation {conversation_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# === Message Operations ===
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
) -> Message:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to add to
|
||||||
|
role: Message role (user, assistant, system, summary)
|
||||||
|
content: Message content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Message object
|
||||||
|
"""
|
||||||
|
# Count tokens
|
||||||
|
token_count = count_tokens(content)
|
||||||
|
|
||||||
|
message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
token_count=token_count,
|
||||||
|
)
|
||||||
|
self.session.add(message)
|
||||||
|
|
||||||
|
# Update conversation total tokens
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if conversation:
|
||||||
|
conversation.total_tokens += token_count
|
||||||
|
|
||||||
|
# Auto-generate title from first user message
|
||||||
|
if conversation.title is None and role == "user":
|
||||||
|
conversation.title = content[:100] + ("..." if len(content) > 100 else "")
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return message
|
||||||
|
|
||||||
|
# === Context Building ===
|
||||||
|
|
||||||
|
def build_context_prompt(
|
||||||
|
self,
|
||||||
|
messages: list[Message],
|
||||||
|
current_message: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build a prompt with conversation context.
|
||||||
|
|
||||||
|
Includes summary (if exists) and recent messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: All conversation messages
|
||||||
|
current_message: The current user message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted prompt with context
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Find most recent summary
|
||||||
|
summaries = [m for m in messages if m.is_summary]
|
||||||
|
if summaries:
|
||||||
|
latest_summary = summaries[-1]
|
||||||
|
parts.append(
|
||||||
|
f"<conversation_summary>\n{latest_summary.content}\n</conversation_summary>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get recent non-summary messages
|
||||||
|
recent = [m for m in messages if not m.is_summary]
|
||||||
|
keep_count = self.settings.keep_recent_messages
|
||||||
|
recent = recent[-keep_count:] if len(recent) > keep_count else recent
|
||||||
|
|
||||||
|
if recent:
|
||||||
|
parts.append("<recent_conversation>")
|
||||||
|
for msg in recent:
|
||||||
|
role_label = msg.role.upper()
|
||||||
|
parts.append(f"{role_label}: {msg.content}")
|
||||||
|
parts.append("</recent_conversation>")
|
||||||
|
|
||||||
|
# Add current message
|
||||||
|
parts.append(f"<current_request>\n{current_message}\n</current_request>")
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
# === Agent Integration ===
|
||||||
|
|
||||||
|
async def get_agent_response(
|
||||||
|
self,
|
||||||
|
conversation_id: UUID,
|
||||||
|
user_message: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Get agent response with conversation context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation ID
|
||||||
|
user_message: Current user message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent's response text
|
||||||
|
"""
|
||||||
|
conversation = await self.get_with_messages(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
raise ValueError(f"Conversation {conversation_id} not found")
|
||||||
|
|
||||||
|
agent = get_agent(conversation.agent_type)
|
||||||
|
if not agent:
|
||||||
|
raise ValueError(f"Unknown agent type: {conversation.agent_type}")
|
||||||
|
|
||||||
|
# Build context prompt
|
||||||
|
context_prompt = self.build_context_prompt(
|
||||||
|
conversation.messages,
|
||||||
|
user_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run agent
|
||||||
|
response = await agent.run(
|
||||||
|
context_prompt,
|
||||||
|
working_dir=conversation.working_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# === Summarization ===
|
||||||
|
|
||||||
|
async def should_summarize(self, conversation_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Check if conversation needs summarization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if summarization should be triggered
|
||||||
|
"""
|
||||||
|
conversation = await self.get(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
return False
|
||||||
|
|
||||||
|
threshold = self.settings.max_context_tokens * self.settings.summarization_threshold
|
||||||
|
return conversation.total_tokens > threshold
|
||||||
|
|
||||||
|
async def summarize_if_needed(self, conversation_id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Summarize old messages if approaching token limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: Conversation to check and potentially summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if summarization was performed
|
||||||
|
"""
|
||||||
|
if not await self.should_summarize(conversation_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
conversation = await self.get_with_messages(conversation_id)
|
||||||
|
if not conversation:
|
||||||
|
return False
|
||||||
|
|
||||||
|
messages = conversation.messages
|
||||||
|
keep_count = self.settings.keep_recent_messages
|
||||||
|
|
||||||
|
# Don't summarize if not enough messages
|
||||||
|
if len(messages) <= keep_count + 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get messages to summarize (exclude recent and existing summaries)
|
||||||
|
non_summary_msgs = [m for m in messages if not m.is_summary]
|
||||||
|
to_summarize = non_summary_msgs[:-keep_count]
|
||||||
|
|
||||||
|
if not to_summarize:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Summarizing {len(to_summarize)} messages in conversation {conversation_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate summary
|
||||||
|
summary_text = await generate_summary(
|
||||||
|
to_summarize,
|
||||||
|
working_dir=conversation.working_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ID of last summarized message
|
||||||
|
last_summarized_id = to_summarize[-1].id
|
||||||
|
|
||||||
|
# Calculate tokens being removed
|
||||||
|
removed_tokens = sum(m.token_count for m in to_summarize)
|
||||||
|
summary_tokens = count_tokens(summary_text)
|
||||||
|
|
||||||
|
# Add summary message
|
||||||
|
summary_message = Message(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role="summary",
|
||||||
|
content=summary_text,
|
||||||
|
token_count=summary_tokens,
|
||||||
|
is_summary=True,
|
||||||
|
summarizes_up_to=last_summarized_id,
|
||||||
|
)
|
||||||
|
self.session.add(summary_message)
|
||||||
|
|
||||||
|
# Mark old messages as summarized (soft delete by excluding from context)
|
||||||
|
for msg in to_summarize:
|
||||||
|
msg.is_summary = True # Reuse flag to mark as "summarized away"
|
||||||
|
|
||||||
|
# Update conversation token count
|
||||||
|
conversation.total_tokens = conversation.total_tokens - removed_tokens + summary_tokens
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Summarization complete: removed {removed_tokens} tokens, "
|
||||||
|
f"added {summary_tokens} token summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
Context summarization for conversations.
|
||||||
|
|
||||||
|
Compresses old messages when approaching token limits.
|
||||||
|
"""
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
SUMMARIZE_PROMPT = """Summarize this conversation history concisely for context preservation.
|
||||||
|
|
||||||
|
Focus on:
|
||||||
|
- Key decisions made and their rationale
|
||||||
|
- Important files, functions, or code discussed
|
||||||
|
- Current task state and progress
|
||||||
|
- Any unresolved questions or blockers
|
||||||
|
- Technical details that would be needed to continue the work
|
||||||
|
|
||||||
|
Keep the summary under 500 words. Be factual and technical, not conversational.
|
||||||
|
Preserve specific file paths, function names, and code references.
|
||||||
|
|
||||||
|
CONVERSATION HISTORY:
|
||||||
|
{history}
|
||||||
|
|
||||||
|
CONCISE SUMMARY:"""
|
||||||
|
|
||||||
|
|
||||||
|
def format_messages_for_summary(messages: list[Message]) -> str:
|
||||||
|
"""
|
||||||
|
Format messages into a string for summarization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of Message objects to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted conversation string
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg.is_summary:
|
||||||
|
parts.append(f"[Previous Summary]: {msg.content}")
|
||||||
|
else:
|
||||||
|
role = msg.role.upper()
|
||||||
|
parts.append(f"{role}: {msg.content}")
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_summary(
|
||||||
|
messages: list[Message],
|
||||||
|
working_dir: str = "."
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a summary of conversation messages using the Explore agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Messages to summarize
|
||||||
|
working_dir: Working directory for agent context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary text
|
||||||
|
"""
|
||||||
|
from src.domains.agents.explore import explore
|
||||||
|
|
||||||
|
history = format_messages_for_summary(messages)
|
||||||
|
prompt = SUMMARIZE_PROMPT.format(history=history)
|
||||||
|
|
||||||
|
logger.info(f"Generating summary for {len(messages)} messages")
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = await explore(prompt, working_dir=working_dir)
|
||||||
|
return summary.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Summary generation failed: {e}")
|
||||||
|
# Fallback: create a simple truncated summary
|
||||||
|
return _fallback_summary(messages)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_summary(messages: list[Message]) -> str:
|
||||||
|
"""
|
||||||
|
Create a simple fallback summary if agent summarization fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Messages to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Basic summary string
|
||||||
|
"""
|
||||||
|
# Take first and last few messages
|
||||||
|
if len(messages) <= 4:
|
||||||
|
return format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
first_two = messages[:2]
|
||||||
|
last_two = messages[-2:]
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
"Conversation started with:",
|
||||||
|
format_messages_for_summary(first_two),
|
||||||
|
f"\n[... {len(messages) - 4} messages omitted ...]\n",
|
||||||
|
"Most recent exchange:",
|
||||||
|
format_messages_for_summary(last_two),
|
||||||
|
]
|
||||||
|
return "\n".join(parts)
|
||||||
@@ -8,6 +8,7 @@ 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.agents.router import router as agents_router
|
||||||
|
from src.domains.conversations.router import router as conversations_router
|
||||||
|
|
||||||
# from src.domains.auth.router import router as auth_router
|
# from src.domains.auth.router import router as auth_router
|
||||||
# from src.domains.tools.router import router as tools_router
|
# from src.domains.tools.router import router as tools_router
|
||||||
@@ -20,6 +21,9 @@ root_router.include_router(health_router)
|
|||||||
# Agents domain (prefix defined in router)
|
# Agents domain (prefix defined in router)
|
||||||
root_router.include_router(agents_router)
|
root_router.include_router(agents_router)
|
||||||
|
|
||||||
|
# Conversations domain (prefix defined in router)
|
||||||
|
root_router.include_router(conversations_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"])
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import httpx
|
|||||||
from src.domains.tools.base import BaseTool, ToolResult
|
from src.domains.tools.base import BaseTool, ToolResult
|
||||||
from src.shared.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import logged, get_logger
|
||||||
|
from src.shared.retry import retry_async
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -73,6 +74,24 @@ IMPORTANT:
|
|||||||
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
|
||||||
self.timeout = timeout or settings.searxng_timeout
|
self.timeout = timeout or settings.searxng_timeout
|
||||||
self.max_results = max_results
|
self.max_results = max_results
|
||||||
|
# Retry settings
|
||||||
|
self.retry_max_attempts = settings.retry_max_attempts
|
||||||
|
self.retry_base_delay = settings.retry_base_delay
|
||||||
|
self.retry_max_delay = settings.retry_max_delay
|
||||||
|
|
||||||
|
async def _fetch_search_results(self, params: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch search results from SearXNG.
|
||||||
|
|
||||||
|
This method is wrapped with retry logic for transient failures.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.searxng_url}/search",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -111,16 +130,15 @@ IMPORTANT:
|
|||||||
params["categories"] = categories
|
params["categories"] = categories
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
data = await retry_async(
|
||||||
response = await client.get(
|
self._fetch_search_results,
|
||||||
f"{self.searxng_url}/search",
|
params,
|
||||||
params=params,
|
max_attempts=self.retry_max_attempts,
|
||||||
)
|
base_delay=self.retry_base_delay,
|
||||||
response.raise_for_status()
|
max_delay=self.retry_max_delay,
|
||||||
data = response.json()
|
)
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
return self._error(f"Search timed out after {self.timeout}s")
|
return self._error(f"Search timed out after {self.timeout}s (all retries exhausted)")
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
return self._error(f"Search failed: HTTP {e.response.status_code}")
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
|
|||||||
@@ -31,13 +31,18 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Port: {settings.port}")
|
logger.info(f"Port: {settings.port}")
|
||||||
logger.info(f"Ollama: {settings.ollama_url}")
|
logger.info(f"Ollama: {settings.ollama_url}")
|
||||||
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
logger.info(f"Agent model: {settings.ollama_agent_model}")
|
||||||
|
logger.info(f"Database: {settings.database_url}")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# TODO: Initialize resources (LLM clients, etc.)
|
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
from src.db import get_database
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
await database.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
logger.info("Shutting down")
|
logger.info("Shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,11 @@ class Settings(BaseSettings):
|
|||||||
ollama_embed_model: str = "nomic-embed-text:latest"
|
ollama_embed_model: str = "nomic-embed-text:latest"
|
||||||
|
|
||||||
# Auth - Tatlock integration
|
# Auth - Tatlock integration
|
||||||
tatlock_api_url: str | None = "http://192.168.86.149:8000"
|
tatlock_api_url: str | None = "http://tatlock:8000"
|
||||||
internal_api_key: str | None = None
|
internal_api_key: str | None = None
|
||||||
|
|
||||||
# Web search - SearXNG (use SEARXNG_URL env var to override)
|
# Web search - SearXNG (use SEARXNG_URL env var to override)
|
||||||
searxng_url: str = "http://192.168.86.149:8087"
|
searxng_url: str = "http://searxng:8080"
|
||||||
searxng_timeout: int = 10
|
searxng_timeout: int = 10
|
||||||
|
|
||||||
# Tool execution
|
# Tool execution
|
||||||
@@ -80,9 +80,20 @@ class Settings(BaseSettings):
|
|||||||
sandbox_enabled: bool = True
|
sandbox_enabled: bool = True
|
||||||
allowed_paths: list[str] | None = None
|
allowed_paths: list[str] | None = None
|
||||||
|
|
||||||
# Sessions
|
# Database
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./webber.db"
|
||||||
|
|
||||||
|
# Sessions & Context
|
||||||
session_ttl_hours: int = 24
|
session_ttl_hours: int = 24
|
||||||
max_context_tokens: int = 128000
|
max_context_tokens: int = 128000
|
||||||
|
summarization_threshold: float = 0.8 # Summarize at 80% of max tokens
|
||||||
|
summarization_target_tokens: int = 500 # Target summary size
|
||||||
|
keep_recent_messages: int = 6 # Messages to keep unsummarized (3 turns)
|
||||||
|
|
||||||
|
# Retry logic
|
||||||
|
retry_max_attempts: int = 3 # Max retry attempts for transient failures
|
||||||
|
retry_base_delay: float = 1.0 # Base delay in seconds
|
||||||
|
retry_max_delay: float = 30.0 # Maximum delay in seconds
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Retry utilities for handling transient failures.
|
||||||
|
|
||||||
|
Provides decorators and helpers for automatic retry with exponential backoff.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
# Exceptions that should trigger a retry
|
||||||
|
RETRYABLE_EXCEPTIONS = (
|
||||||
|
httpx.TimeoutException,
|
||||||
|
httpx.ConnectError,
|
||||||
|
httpx.ReadError,
|
||||||
|
httpx.WriteError,
|
||||||
|
httpx.ConnectTimeout,
|
||||||
|
httpx.ReadTimeout,
|
||||||
|
httpx.WriteTimeout,
|
||||||
|
httpx.PoolTimeout,
|
||||||
|
ConnectionError,
|
||||||
|
TimeoutError,
|
||||||
|
OSError, # Covers many network-related errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable_http_status(status_code: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an HTTP status code should trigger a retry.
|
||||||
|
|
||||||
|
Retryable:
|
||||||
|
- 429 Too Many Requests (rate limited)
|
||||||
|
- 500 Internal Server Error
|
||||||
|
- 502 Bad Gateway
|
||||||
|
- 503 Service Unavailable
|
||||||
|
- 504 Gateway Timeout
|
||||||
|
"""
|
||||||
|
return status_code in (429, 500, 502, 503, 504)
|
||||||
|
|
||||||
|
|
||||||
|
def is_retryable_exception(exc: Exception) -> bool:
|
||||||
|
"""Check if an exception should trigger a retry."""
|
||||||
|
if isinstance(exc, RETRYABLE_EXCEPTIONS):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for retryable HTTP status codes
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
return is_retryable_http_status(exc.response.status_code)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_backoff(
|
||||||
|
attempt: int,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
jitter: bool = True,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate exponential backoff delay with optional jitter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current attempt number (0-indexed)
|
||||||
|
base_delay: Base delay in seconds
|
||||||
|
max_delay: Maximum delay in seconds
|
||||||
|
jitter: Add random jitter to prevent thundering herd
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Delay in seconds
|
||||||
|
"""
|
||||||
|
# Exponential backoff: base_delay * 2^attempt
|
||||||
|
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||||
|
|
||||||
|
if jitter:
|
||||||
|
# Add up to 25% random jitter
|
||||||
|
delay = delay * (0.75 + random.random() * 0.5)
|
||||||
|
|
||||||
|
return delay
|
||||||
|
|
||||||
|
|
||||||
|
def with_retry(
|
||||||
|
max_attempts: int = 3,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
retryable_exceptions: tuple[type[Exception], ...] | None = None,
|
||||||
|
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
|
||||||
|
"""
|
||||||
|
Decorator for async functions that should retry on transient failures.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_attempts: Maximum number of attempts (including initial)
|
||||||
|
base_delay: Base delay between retries in seconds
|
||||||
|
max_delay: Maximum delay between retries in seconds
|
||||||
|
retryable_exceptions: Additional exceptions to retry on
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function with retry logic
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@with_retry(max_attempts=3, base_delay=1.0)
|
||||||
|
async def fetch_data():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
return response.json()
|
||||||
|
"""
|
||||||
|
extra_exceptions = retryable_exceptions or ()
|
||||||
|
|
||||||
|
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> T:
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
except (*RETRYABLE_EXCEPTIONS, *extra_exceptions) as e:
|
||||||
|
last_exception = e
|
||||||
|
should_retry = True
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
last_exception = e
|
||||||
|
should_retry = is_retryable_http_status(e.response.status_code)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Non-retryable exception, re-raise immediately
|
||||||
|
raise
|
||||||
|
|
||||||
|
if should_retry and attempt < max_attempts - 1:
|
||||||
|
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||||
|
logger.warning(
|
||||||
|
f"Retry {attempt + 1}/{max_attempts - 1} for {func.__name__} "
|
||||||
|
f"after {delay:.2f}s due to: {last_exception}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
elif not should_retry:
|
||||||
|
# Non-retryable HTTP error
|
||||||
|
raise last_exception # type: ignore
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
logger.error(
|
||||||
|
f"All {max_attempts} attempts failed for {func.__name__}: {last_exception}"
|
||||||
|
)
|
||||||
|
raise last_exception # type: ignore
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_async(
|
||||||
|
func: Callable[..., Awaitable[T]],
|
||||||
|
*args: Any,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
base_delay: float = 1.0,
|
||||||
|
max_delay: float = 60.0,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
Retry an async function with exponential backoff.
|
||||||
|
|
||||||
|
Alternative to decorator when you need per-call control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Async function to call
|
||||||
|
*args: Positional arguments for func
|
||||||
|
max_attempts: Maximum number of attempts
|
||||||
|
base_delay: Base delay between retries
|
||||||
|
max_delay: Maximum delay between retries
|
||||||
|
**kwargs: Keyword arguments for func
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of func
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Last exception if all retries fail
|
||||||
|
|
||||||
|
Example:
|
||||||
|
result = await retry_async(
|
||||||
|
fetch_data,
|
||||||
|
url,
|
||||||
|
max_attempts=5,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
|
||||||
|
if not is_retryable_exception(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
if attempt < max_attempts - 1:
|
||||||
|
delay = calculate_backoff(attempt, base_delay, max_delay)
|
||||||
|
logger.warning(
|
||||||
|
f"Retry {attempt + 1}/{max_attempts - 1} "
|
||||||
|
f"after {delay:.2f}s due to: {e}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
raise last_exception # type: ignore
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Token counting utilities for context management.
|
||||||
|
|
||||||
|
Uses tiktoken for token counting. While tiktoken is OpenAI's tokenizer,
|
||||||
|
cl100k_base encoding provides reasonable estimates for most LLMs.
|
||||||
|
"""
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from src.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _get_encoding():
|
||||||
|
"""Get tiktoken encoding (cached)."""
|
||||||
|
import tiktoken
|
||||||
|
# cl100k_base is used by GPT-4 and provides reasonable estimates for most models
|
||||||
|
return tiktoken.get_encoding("cl100k_base")
|
||||||
|
|
||||||
|
|
||||||
|
def count_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Count tokens in a text string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to count tokens for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
encoding = _get_encoding()
|
||||||
|
return len(encoding.encode(text))
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to rough estimate if tiktoken fails
|
||||||
|
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||||
|
return len(text) // 4
|
||||||
|
|
||||||
|
|
||||||
|
def count_message_tokens(messages: list[dict[str, str]]) -> int:
|
||||||
|
"""
|
||||||
|
Count tokens for a list of chat messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content' keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total token count including message overhead
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
encoding = _get_encoding()
|
||||||
|
total = 0
|
||||||
|
for msg in messages:
|
||||||
|
# Each message has ~4 tokens overhead for role/formatting
|
||||||
|
total += 4
|
||||||
|
total += len(encoding.encode(msg.get("content", "")))
|
||||||
|
total += len(encoding.encode(msg.get("role", "")))
|
||||||
|
# Add 2 tokens for assistant response priming
|
||||||
|
total += 2
|
||||||
|
return total
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to rough estimate
|
||||||
|
logger.warning(f"Token counting failed, using estimate: {e}")
|
||||||
|
total = 0
|
||||||
|
for msg in messages:
|
||||||
|
total += len(msg.get("content", "")) // 4
|
||||||
|
total += 4 # Overhead per message
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Quick token estimate without external library.
|
||||||
|
|
||||||
|
Uses ~4 characters per token heuristic.
|
||||||
|
Less accurate but faster for rough estimates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to estimate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count
|
||||||
|
"""
|
||||||
|
return len(text) // 4
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Tests for agent REST API endpoints.
|
Tests for agent REST API endpoints.
|
||||||
|
|
||||||
|
Includes integration tests that verify real code paths work correctly
|
||||||
|
without over-mocking (only LLM calls are mocked).
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from src.domains.agents.schemas import PermissionMode
|
||||||
|
|
||||||
|
|
||||||
class TestAgentListEndpoint:
|
class TestAgentListEndpoint:
|
||||||
@@ -160,3 +166,182 @@ class TestAgentStreamEndpoint:
|
|||||||
)
|
)
|
||||||
# Unknown agent returns 400, not streaming
|
# Unknown agent returns 400, not streaming
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionModeIntegration:
|
||||||
|
"""
|
||||||
|
Integration tests for permission mode handling.
|
||||||
|
|
||||||
|
These tests verify that mode strings are correctly converted to enums
|
||||||
|
and that the full request->router->agent flow works for each mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_with_default_mode(self, auth_client):
|
||||||
|
"""Test running agent with default mode passes through correctly."""
|
||||||
|
# Mock the agent.run method to avoid LLM calls
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.output = "Test response"
|
||||||
|
|
||||||
|
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"prompt": "test prompt",
|
||||||
|
"agent_type": "task",
|
||||||
|
"working_dir": ".",
|
||||||
|
"mode": "default"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["mode"] == "default"
|
||||||
|
|
||||||
|
# Verify mode was converted to enum and passed correctly
|
||||||
|
mock_get_agent.assert_called_once_with(PermissionMode.default)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_with_plan_mode(self, auth_client):
|
||||||
|
"""Test running agent with plan mode passes through correctly."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.output = "Plan response"
|
||||||
|
|
||||||
|
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"prompt": "test prompt",
|
||||||
|
"agent_type": "task",
|
||||||
|
"working_dir": ".",
|
||||||
|
"mode": "plan"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["mode"] == "plan"
|
||||||
|
mock_get_agent.assert_called_once_with(PermissionMode.plan)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_with_auto_accept_mode(self, auth_client):
|
||||||
|
"""Test running agent with auto_accept mode passes through correctly."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.output = "Auto accept response"
|
||||||
|
|
||||||
|
with patch("src.domains.agents.task.agent.TaskAgentImpl._get_agent_for_mode") as mock_get_agent:
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.run = AsyncMock(return_value=mock_result)
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"prompt": "test prompt",
|
||||||
|
"agent_type": "task",
|
||||||
|
"working_dir": ".",
|
||||||
|
"mode": "auto_accept"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["mode"] == "auto_accept"
|
||||||
|
mock_get_agent.assert_called_once_with(PermissionMode.auto_accept)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_with_invalid_mode(self, auth_client):
|
||||||
|
"""Test running agent with invalid mode returns validation error."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"prompt": "test prompt",
|
||||||
|
"agent_type": "task",
|
||||||
|
"working_dir": ".",
|
||||||
|
"mode": "invalid_mode"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_with_plan_mode(self, auth_client):
|
||||||
|
"""Test streaming agent with plan mode passes through correctly."""
|
||||||
|
from src.domains.agents.schemas import StreamEvent, StreamEventType
|
||||||
|
|
||||||
|
async def mock_event_stream(*args, **kwargs):
|
||||||
|
"""Mock event-based stream."""
|
||||||
|
yield StreamEvent(event=StreamEventType.thinking, message="Starting...")
|
||||||
|
yield StreamEvent(event=StreamEventType.response, text="chunk1")
|
||||||
|
yield StreamEvent(event=StreamEventType.response, text="chunk2")
|
||||||
|
yield StreamEvent(event=StreamEventType.done, mode="plan")
|
||||||
|
|
||||||
|
with patch("src.domains.agents.task.agent.TaskAgentImpl.run_stream") as mock_run_stream:
|
||||||
|
mock_run_stream.return_value = mock_event_stream()
|
||||||
|
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"prompt": "test prompt",
|
||||||
|
"agent_type": "task",
|
||||||
|
"working_dir": ".",
|
||||||
|
"mode": "plan"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||||
|
mock_run_stream.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentMethodSignatures:
|
||||||
|
"""
|
||||||
|
Tests to verify agent method signatures match expected interfaces.
|
||||||
|
|
||||||
|
These catch issues like passing invalid kwargs to methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_task_agent_run_accepts_mode(self):
|
||||||
|
"""Verify TaskAgentImpl.run() accepts mode parameter."""
|
||||||
|
from src.domains.agents.task.agent import TaskAgentImpl
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(TaskAgentImpl.run)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
|
||||||
|
assert "mode" in params
|
||||||
|
# Verify mode has correct type annotation
|
||||||
|
mode_param = sig.parameters["mode"]
|
||||||
|
assert mode_param.default == PermissionMode.default
|
||||||
|
|
||||||
|
def test_task_agent_run_stream_accepts_mode(self):
|
||||||
|
"""Verify TaskAgentImpl.run_stream() accepts mode parameter."""
|
||||||
|
from src.domains.agents.task.agent import TaskAgentImpl
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(TaskAgentImpl.run_stream)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
|
||||||
|
assert "mode" in params
|
||||||
|
mode_param = sig.parameters["mode"]
|
||||||
|
assert mode_param.default == PermissionMode.default
|
||||||
|
|
||||||
|
def test_trace_span_signature(self):
|
||||||
|
"""Verify trace_span only accepts expected parameters."""
|
||||||
|
from src.shared.logging import trace_span
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(trace_span.__init__)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
|
||||||
|
# Should only have self, name, logger - not mode or other extras
|
||||||
|
assert params == ["self", "name", "logger"]
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""
|
||||||
|
Tests for conversations domain.
|
||||||
|
|
||||||
|
Tests conversation CRUD, context building, and API endpoints.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from src.domains.conversations.models import Conversation, Message
|
||||||
|
from src.domains.conversations.schemas import (
|
||||||
|
CreateConversationRequest,
|
||||||
|
AddMessageRequest,
|
||||||
|
ConversationResponse,
|
||||||
|
MessageResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationModels:
|
||||||
|
"""Tests for conversation database models."""
|
||||||
|
|
||||||
|
def test_conversation_creation(self):
|
||||||
|
"""Test Conversation model creation with explicit values."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id="test-user",
|
||||||
|
agent_type="explore",
|
||||||
|
working_dir=".",
|
||||||
|
total_tokens=0,
|
||||||
|
)
|
||||||
|
assert conv.user_id == "test-user"
|
||||||
|
assert conv.agent_type == "explore"
|
||||||
|
assert conv.working_dir == "."
|
||||||
|
assert conv.total_tokens == 0
|
||||||
|
|
||||||
|
def test_conversation_with_values(self):
|
||||||
|
"""Test Conversation with explicit values."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id="test-user",
|
||||||
|
agent_type="plan",
|
||||||
|
working_dir="/tmp/project",
|
||||||
|
title="Test Conversation",
|
||||||
|
)
|
||||||
|
assert conv.agent_type == "plan"
|
||||||
|
assert conv.working_dir == "/tmp/project"
|
||||||
|
assert conv.title == "Test Conversation"
|
||||||
|
|
||||||
|
def test_message_creation(self):
|
||||||
|
"""Test Message model creation with explicit values."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Hello",
|
||||||
|
token_count=0,
|
||||||
|
is_summary=False,
|
||||||
|
)
|
||||||
|
assert msg.role == "user"
|
||||||
|
assert msg.content == "Hello"
|
||||||
|
assert msg.token_count == 0
|
||||||
|
assert msg.is_summary is False
|
||||||
|
|
||||||
|
def test_message_repr(self):
|
||||||
|
"""Test Message string representation."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="This is a test message",
|
||||||
|
)
|
||||||
|
repr_str = repr(msg)
|
||||||
|
assert "user" in repr_str
|
||||||
|
assert "This is a test" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationSchemas:
|
||||||
|
"""Tests for Pydantic schemas."""
|
||||||
|
|
||||||
|
def test_create_request_defaults(self):
|
||||||
|
"""Test CreateConversationRequest defaults."""
|
||||||
|
request = CreateConversationRequest()
|
||||||
|
assert request.agent_type == "explore"
|
||||||
|
assert request.working_dir == "."
|
||||||
|
assert request.title is None
|
||||||
|
|
||||||
|
def test_create_request_custom(self):
|
||||||
|
"""Test CreateConversationRequest with values."""
|
||||||
|
request = CreateConversationRequest(
|
||||||
|
agent_type="task",
|
||||||
|
working_dir="/home/user/project",
|
||||||
|
title="My Task",
|
||||||
|
)
|
||||||
|
assert request.agent_type == "task"
|
||||||
|
assert request.working_dir == "/home/user/project"
|
||||||
|
assert request.title == "My Task"
|
||||||
|
|
||||||
|
def test_add_message_request_valid(self):
|
||||||
|
"""Test AddMessageRequest validation."""
|
||||||
|
request = AddMessageRequest(content="Hello, world!")
|
||||||
|
assert request.content == "Hello, world!"
|
||||||
|
|
||||||
|
def test_add_message_request_empty_fails(self):
|
||||||
|
"""Test that empty content fails validation."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
AddMessageRequest(content="")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationAPI:
|
||||||
|
"""Tests for conversation API endpoints."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_conversation(self, auth_client):
|
||||||
|
"""Test creating a conversation."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "explore", "working_dir": "."}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert "id" in data
|
||||||
|
assert data["agent_type"] == "explore"
|
||||||
|
assert data["total_tokens"] == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_conversation_with_title(self, auth_client):
|
||||||
|
"""Test creating a conversation with title."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
"working_dir": "/tmp",
|
||||||
|
"title": "Planning Session"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["title"] == "Planning Session"
|
||||||
|
assert data["agent_type"] == "plan"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_conversations_empty(self, auth_client):
|
||||||
|
"""Test listing conversations when empty."""
|
||||||
|
response = await auth_client.get("/conversations/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "conversations" in data
|
||||||
|
assert "total" in data
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_conversation_not_found(self, auth_client):
|
||||||
|
"""Test getting non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.get(f"/conversations/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_delete_conversation_not_found(self, auth_client):
|
||||||
|
"""Test deleting non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.delete(f"/conversations/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_add_message_not_found(self, auth_client):
|
||||||
|
"""Test adding message to non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{fake_id}/messages",
|
||||||
|
json={"content": "Hello"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationService:
|
||||||
|
"""Tests for ConversationService business logic."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_no_history(self):
|
||||||
|
"""Test building context prompt with no history."""
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Create mock session
|
||||||
|
mock_session = MagicMock()
|
||||||
|
service = ConversationService(mock_session)
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt([], "What files are here?")
|
||||||
|
|
||||||
|
assert "<current_request>" in prompt
|
||||||
|
assert "What files are here?" in prompt
|
||||||
|
assert "<recent_conversation>" not in prompt
|
||||||
|
assert "<conversation_summary>" not in prompt
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_with_history(self):
|
||||||
|
"""Test building context prompt with message history."""
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
service = ConversationService(mock_session)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Find Python files",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="assistant",
|
||||||
|
content="Found 10 Python files.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt(messages, "Show the largest")
|
||||||
|
|
||||||
|
assert "<recent_conversation>" in prompt
|
||||||
|
assert "USER: Find Python files" in prompt
|
||||||
|
assert "ASSISTANT: Found 10 Python files" in prompt
|
||||||
|
assert "<current_request>" in prompt
|
||||||
|
assert "Show the largest" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_context_prompt_with_summary(self):
|
||||||
|
"""Test building context prompt with summary message."""
|
||||||
|
from src.domains.conversations.service import ConversationService
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
service = ConversationService(mock_session)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="summary",
|
||||||
|
content="Previously discussed: project setup",
|
||||||
|
is_summary=True,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Now what?",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = service.build_context_prompt(messages, "Continue")
|
||||||
|
|
||||||
|
assert "<conversation_summary>" in prompt
|
||||||
|
assert "Previously discussed: project setup" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummarization:
|
||||||
|
"""Tests for conversation summarization."""
|
||||||
|
|
||||||
|
def test_format_messages_for_summary(self):
|
||||||
|
"""Test formatting messages for summarization."""
|
||||||
|
from src.domains.conversations.summarize import format_messages_for_summary
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Hello",
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="assistant",
|
||||||
|
content="Hi there!",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
formatted = format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
assert "USER: Hello" in formatted
|
||||||
|
assert "ASSISTANT: Hi there!" in formatted
|
||||||
|
|
||||||
|
def test_format_messages_with_summary(self):
|
||||||
|
"""Test formatting messages that include a summary."""
|
||||||
|
from src.domains.conversations.summarize import format_messages_for_summary
|
||||||
|
from src.domains.conversations.models import Message
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="summary",
|
||||||
|
content="Previous context summary",
|
||||||
|
is_summary=True,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
conversation_id=uuid4(),
|
||||||
|
role="user",
|
||||||
|
content="Continue",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
formatted = format_messages_for_summary(messages)
|
||||||
|
|
||||||
|
assert "[Previous Summary]" in formatted
|
||||||
|
assert "Previous context summary" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveMessagesAPI:
|
||||||
|
"""Tests for the save messages endpoint (no agent execution)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages(self, auth_client):
|
||||||
|
"""Test saving a message pair without triggering agent."""
|
||||||
|
# First create a conversation
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "task", "working_dir": "."}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
conv_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Save a message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Find all Python files",
|
||||||
|
"assistant_content": "I found 5 Python files in the project.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["user_message"]["role"] == "user"
|
||||||
|
assert data["user_message"]["content"] == "Find all Python files"
|
||||||
|
assert data["assistant_message"]["role"] == "assistant"
|
||||||
|
assert data["assistant_message"]["content"] == "I found 5 Python files in the project."
|
||||||
|
assert data["total_tokens"] > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages_not_found(self, auth_client):
|
||||||
|
"""Test saving messages to non-existent conversation."""
|
||||||
|
fake_id = uuid4()
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{fake_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Test",
|
||||||
|
"assistant_content": "Response",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_save_messages_updates_token_count(self, auth_client):
|
||||||
|
"""Test that saving messages updates the conversation token count."""
|
||||||
|
# Create conversation
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={"agent_type": "explore", "working_dir": "."}
|
||||||
|
)
|
||||||
|
conv_id = response.json()["id"]
|
||||||
|
assert response.json()["total_tokens"] == 0
|
||||||
|
|
||||||
|
# Save first message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "Hello",
|
||||||
|
"assistant_content": "Hi there!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
first_tokens = response.json()["total_tokens"]
|
||||||
|
assert first_tokens > 0
|
||||||
|
|
||||||
|
# Save second message pair
|
||||||
|
response = await auth_client.post(
|
||||||
|
f"/conversations/{conv_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": "How are you?",
|
||||||
|
"assistant_content": "I'm doing well, thank you for asking!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
second_tokens = response.json()["total_tokens"]
|
||||||
|
assert second_tokens > first_tokens
|
||||||
|
|
||||||
|
# Verify via get endpoint
|
||||||
|
response = await auth_client.get(f"/conversations/{conv_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["total_tokens"] == second_tokens
|
||||||
|
assert len(response.json()["messages"]) == 4
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Plan agent.
|
||||||
|
|
||||||
|
Tests registration, API endpoints, and tool restrictions.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent, list_agents
|
||||||
|
from src.domains.agents.plan import plan_agent, PlanAgentImpl
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentRegistration:
|
||||||
|
"""Tests for Plan agent registration."""
|
||||||
|
|
||||||
|
def test_plan_agent_registered(self):
|
||||||
|
"""Test that plan agent is registered in registry."""
|
||||||
|
agent = get_agent("plan")
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "plan"
|
||||||
|
|
||||||
|
def test_plan_agent_in_list(self):
|
||||||
|
"""Test that plan agent appears in agent list."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
assert "plan" in names
|
||||||
|
|
||||||
|
def test_plan_agent_has_description(self):
|
||||||
|
"""Test that plan agent has a description."""
|
||||||
|
agent = get_agent("plan")
|
||||||
|
assert agent is not None
|
||||||
|
assert len(agent.description) > 0
|
||||||
|
assert "plan" in agent.description.lower() or "architect" in agent.description.lower()
|
||||||
|
|
||||||
|
def test_plan_agent_singleton(self):
|
||||||
|
"""Test that plan_agent is the registered instance."""
|
||||||
|
registered = get_agent("plan")
|
||||||
|
assert registered is plan_agent
|
||||||
|
|
||||||
|
def test_plan_agent_is_correct_type(self):
|
||||||
|
"""Test that plan agent is correct implementation type."""
|
||||||
|
assert isinstance(plan_agent, PlanAgentImpl)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentTools:
|
||||||
|
"""Tests for Plan agent tool restrictions."""
|
||||||
|
|
||||||
|
def test_plan_agent_has_read_only_tools(self):
|
||||||
|
"""Test that plan agent has read-only tools."""
|
||||||
|
# Access the underlying PydanticAI agent to check tools
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should have read-only tools
|
||||||
|
assert "read_file" in tool_names
|
||||||
|
assert "glob_files" in tool_names
|
||||||
|
assert "grep_content" in tool_names
|
||||||
|
assert "bash_readonly" in tool_names
|
||||||
|
|
||||||
|
def test_plan_agent_no_write_tools(self):
|
||||||
|
"""Test that plan agent does NOT have write tools."""
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should NOT have write tools
|
||||||
|
assert "edit_file" not in tool_names
|
||||||
|
assert "write_file" not in tool_names
|
||||||
|
assert "bash" not in tool_names
|
||||||
|
assert "web_search" not in tool_names
|
||||||
|
|
||||||
|
def test_plan_agent_tool_count(self):
|
||||||
|
"""Test that plan agent has exactly 4 tools."""
|
||||||
|
agent = plan_agent.agent
|
||||||
|
tool_count = len(agent._function_toolset.tools)
|
||||||
|
assert tool_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentAPI:
|
||||||
|
"""Tests for Plan agent REST API."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_agents_includes_plan(self, auth_client):
|
||||||
|
"""Test that agent list includes plan agent."""
|
||||||
|
response = await auth_client.get("/agents/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [a["name"] for a in data["agents"]]
|
||||||
|
assert "plan" in names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_plan_agent_info(self, auth_client):
|
||||||
|
"""Test getting plan agent info."""
|
||||||
|
response = await auth_client.get("/agents/plan")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "plan"
|
||||||
|
assert "description" in data
|
||||||
|
assert len(data["description"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_plan_with_invalid_body(self, auth_client):
|
||||||
|
"""Test running plan agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_plan_with_invalid_body(self, auth_client):
|
||||||
|
"""Test streaming plan agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"agent_type": "plan",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanAgentProperties:
|
||||||
|
"""Tests for Plan agent properties and configuration."""
|
||||||
|
|
||||||
|
def test_plan_agent_name(self):
|
||||||
|
"""Test plan agent name property."""
|
||||||
|
assert plan_agent.name == "plan"
|
||||||
|
|
||||||
|
def test_plan_agent_description_not_empty(self):
|
||||||
|
"""Test plan agent description is not empty."""
|
||||||
|
assert plan_agent.description
|
||||||
|
assert len(plan_agent.description) > 10
|
||||||
|
|
||||||
|
def test_plan_agent_creates_agent_lazily(self):
|
||||||
|
"""Test that PydanticAI agent is created lazily."""
|
||||||
|
# Create a fresh instance
|
||||||
|
fresh_agent = PlanAgentImpl()
|
||||||
|
|
||||||
|
# _agent should be None before first access
|
||||||
|
assert fresh_agent._agent is None
|
||||||
|
|
||||||
|
# Access the agent property
|
||||||
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
|
# Now _agent should be set
|
||||||
|
assert fresh_agent._agent is not None
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""
|
||||||
|
Tests for retry utilities.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.shared.retry import (
|
||||||
|
with_retry,
|
||||||
|
retry_async,
|
||||||
|
is_retryable_exception,
|
||||||
|
is_retryable_http_status,
|
||||||
|
calculate_backoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsRetryableHttpStatus:
|
||||||
|
"""Tests for HTTP status code checking."""
|
||||||
|
|
||||||
|
def test_429_is_retryable(self):
|
||||||
|
"""429 Too Many Requests should be retryable."""
|
||||||
|
assert is_retryable_http_status(429) is True
|
||||||
|
|
||||||
|
def test_500_is_retryable(self):
|
||||||
|
"""500 Internal Server Error should be retryable."""
|
||||||
|
assert is_retryable_http_status(500) is True
|
||||||
|
|
||||||
|
def test_502_is_retryable(self):
|
||||||
|
"""502 Bad Gateway should be retryable."""
|
||||||
|
assert is_retryable_http_status(502) is True
|
||||||
|
|
||||||
|
def test_503_is_retryable(self):
|
||||||
|
"""503 Service Unavailable should be retryable."""
|
||||||
|
assert is_retryable_http_status(503) is True
|
||||||
|
|
||||||
|
def test_504_is_retryable(self):
|
||||||
|
"""504 Gateway Timeout should be retryable."""
|
||||||
|
assert is_retryable_http_status(504) is True
|
||||||
|
|
||||||
|
def test_400_not_retryable(self):
|
||||||
|
"""400 Bad Request should not be retryable."""
|
||||||
|
assert is_retryable_http_status(400) is False
|
||||||
|
|
||||||
|
def test_401_not_retryable(self):
|
||||||
|
"""401 Unauthorized should not be retryable."""
|
||||||
|
assert is_retryable_http_status(401) is False
|
||||||
|
|
||||||
|
def test_404_not_retryable(self):
|
||||||
|
"""404 Not Found should not be retryable."""
|
||||||
|
assert is_retryable_http_status(404) is False
|
||||||
|
|
||||||
|
def test_200_not_retryable(self):
|
||||||
|
"""200 OK should not be retryable."""
|
||||||
|
assert is_retryable_http_status(200) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsRetryableException:
|
||||||
|
"""Tests for exception checking."""
|
||||||
|
|
||||||
|
def test_timeout_exception_is_retryable(self):
|
||||||
|
"""Timeout exceptions should be retryable."""
|
||||||
|
exc = httpx.TimeoutException("timeout")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_connect_error_is_retryable(self):
|
||||||
|
"""Connection errors should be retryable."""
|
||||||
|
exc = httpx.ConnectError("connection failed")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_connection_error_is_retryable(self):
|
||||||
|
"""Python ConnectionError should be retryable."""
|
||||||
|
exc = ConnectionError("connection refused")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_timeout_error_is_retryable(self):
|
||||||
|
"""Python TimeoutError should be retryable."""
|
||||||
|
exc = TimeoutError("timed out")
|
||||||
|
assert is_retryable_exception(exc) is True
|
||||||
|
|
||||||
|
def test_value_error_not_retryable(self):
|
||||||
|
"""ValueError should not be retryable."""
|
||||||
|
exc = ValueError("invalid value")
|
||||||
|
assert is_retryable_exception(exc) is False
|
||||||
|
|
||||||
|
def test_key_error_not_retryable(self):
|
||||||
|
"""KeyError should not be retryable."""
|
||||||
|
exc = KeyError("missing key")
|
||||||
|
assert is_retryable_exception(exc) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateBackoff:
|
||||||
|
"""Tests for backoff calculation."""
|
||||||
|
|
||||||
|
def test_first_attempt_base_delay(self):
|
||||||
|
"""First attempt should use base delay."""
|
||||||
|
delay = calculate_backoff(0, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 1.0
|
||||||
|
|
||||||
|
def test_second_attempt_doubles(self):
|
||||||
|
"""Second attempt should double the delay."""
|
||||||
|
delay = calculate_backoff(1, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 2.0
|
||||||
|
|
||||||
|
def test_third_attempt_quadruples(self):
|
||||||
|
"""Third attempt should quadruple the delay."""
|
||||||
|
delay = calculate_backoff(2, base_delay=1.0, jitter=False)
|
||||||
|
assert delay == 4.0
|
||||||
|
|
||||||
|
def test_max_delay_respected(self):
|
||||||
|
"""Delay should not exceed max_delay."""
|
||||||
|
delay = calculate_backoff(10, base_delay=1.0, max_delay=30.0, jitter=False)
|
||||||
|
assert delay == 30.0
|
||||||
|
|
||||||
|
def test_jitter_adds_randomness(self):
|
||||||
|
"""Jitter should add randomness to delay."""
|
||||||
|
delays = [calculate_backoff(1, base_delay=1.0, jitter=True) for _ in range(10)]
|
||||||
|
# With jitter, delays should vary (not all identical)
|
||||||
|
assert len(set(delays)) > 1
|
||||||
|
|
||||||
|
def test_jitter_within_bounds(self):
|
||||||
|
"""Jitter should keep delay within reasonable bounds."""
|
||||||
|
for _ in range(100):
|
||||||
|
delay = calculate_backoff(0, base_delay=2.0, jitter=True)
|
||||||
|
# Attempt 0 with base 2.0 = 2.0, with jitter should be 0.75-1.25x = 1.5-2.5
|
||||||
|
assert 1.5 <= delay <= 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithRetryDecorator:
|
||||||
|
"""Tests for the @with_retry decorator."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_success_on_first_attempt(self):
|
||||||
|
"""Function should return on first successful attempt."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3)
|
||||||
|
async def successful_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await successful_func()
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_retry_on_timeout(self):
|
||||||
|
"""Should retry on timeout exception."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3, base_delay=0.01)
|
||||||
|
async def flaky_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 3:
|
||||||
|
raise httpx.TimeoutException("timeout")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await flaky_func()
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_retry_on_value_error(self):
|
||||||
|
"""Should not retry on non-retryable exceptions."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3)
|
||||||
|
async def bad_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise ValueError("bad value")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await bad_func()
|
||||||
|
assert call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_exhausted_retries(self):
|
||||||
|
"""Should raise last exception after all retries exhausted."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
@with_retry(max_attempts=3, base_delay=0.01)
|
||||||
|
async def always_fails():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise httpx.TimeoutException("always times out")
|
||||||
|
|
||||||
|
with pytest.raises(httpx.TimeoutException):
|
||||||
|
await always_fails()
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryAsync:
|
||||||
|
"""Tests for the retry_async function."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_success_on_first_attempt(self):
|
||||||
|
"""Function should return on first successful attempt."""
|
||||||
|
async def successful_func():
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await retry_async(successful_func, max_attempts=3)
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_retry_on_connect_error(self):
|
||||||
|
"""Should retry on connection errors."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def flaky_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 2:
|
||||||
|
raise httpx.ConnectError("connection failed")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = await retry_async(flaky_func, max_attempts=3, base_delay=0.01)
|
||||||
|
assert result == "success"
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_passes_args_and_kwargs(self):
|
||||||
|
"""Should pass arguments to the function."""
|
||||||
|
async def add(a, b, multiplier=1):
|
||||||
|
return (a + b) * multiplier
|
||||||
|
|
||||||
|
result = await retry_async(add, 2, 3, max_attempts=1, multiplier=2)
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_retry_on_key_error(self):
|
||||||
|
"""Should not retry on non-retryable exceptions."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def bad_func():
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise KeyError("missing")
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
await retry_async(bad_func, max_attempts=3)
|
||||||
|
assert call_count == 1
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Task agent.
|
||||||
|
|
||||||
|
Tests registration, API endpoints, tool access, and spawn_agent functionality.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from src.domains.agents.base import get_agent, list_agents
|
||||||
|
from src.domains.agents.task import task_agent, TaskAgentImpl
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentRegistration:
|
||||||
|
"""Tests for Task agent registration."""
|
||||||
|
|
||||||
|
def test_task_agent_registered(self):
|
||||||
|
"""Test that task agent is registered in registry."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_in_list(self):
|
||||||
|
"""Test that task agent appears in agent list."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
def test_task_agent_has_description(self):
|
||||||
|
"""Test that task agent has a description."""
|
||||||
|
agent = get_agent("task")
|
||||||
|
assert agent is not None
|
||||||
|
assert len(agent.description) > 0
|
||||||
|
assert "task" in agent.description.lower() or "autonomous" in agent.description.lower()
|
||||||
|
|
||||||
|
def test_task_agent_singleton(self):
|
||||||
|
"""Test that task_agent is the registered instance."""
|
||||||
|
registered = get_agent("task")
|
||||||
|
assert registered is task_agent
|
||||||
|
|
||||||
|
def test_task_agent_is_correct_type(self):
|
||||||
|
"""Test that task agent is correct implementation type."""
|
||||||
|
assert isinstance(task_agent, TaskAgentImpl)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentTools:
|
||||||
|
"""Tests for Task agent tool access."""
|
||||||
|
|
||||||
|
def test_task_agent_has_all_tools(self):
|
||||||
|
"""Test that task agent has all 9 tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Should have 9 tools total
|
||||||
|
assert len(tool_names) == 9
|
||||||
|
|
||||||
|
def test_task_agent_has_read_only_tools(self):
|
||||||
|
"""Test that task agent has read-only tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "read_file" in tool_names
|
||||||
|
assert "glob_files" in tool_names
|
||||||
|
assert "grep_content" in tool_names
|
||||||
|
assert "bash_readonly" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_write_tools(self):
|
||||||
|
"""Test that task agent has write tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "edit_file" in tool_names
|
||||||
|
assert "write_file" in tool_names
|
||||||
|
assert "bash" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_external_tools(self):
|
||||||
|
"""Test that task agent has external tools."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "web_search" in tool_names
|
||||||
|
|
||||||
|
def test_task_agent_has_spawn_agent_tool(self):
|
||||||
|
"""Test that task agent has spawn_agent orchestration tool."""
|
||||||
|
agent = task_agent.agent
|
||||||
|
tool_names = list(agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
assert "spawn_agent" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpawnAgentTool:
|
||||||
|
"""Tests for spawn_agent orchestration functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_spawn_explore_agent(self):
|
||||||
|
"""Test spawning an explore agent."""
|
||||||
|
from src.domains.agents.task.tools import register_task_tools
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from pydantic_ai import Agent, RunContext
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Create a mock context
|
||||||
|
ctx = MagicMock(spec=RunContext)
|
||||||
|
ctx.deps = AgentContext(
|
||||||
|
working_dir="/tmp",
|
||||||
|
allowed_paths=["/tmp"],
|
||||||
|
timeout_seconds=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the explore agent
|
||||||
|
with patch("src.domains.agents.base.get_agent") as mock_get_agent:
|
||||||
|
mock_explore = AsyncMock()
|
||||||
|
mock_explore.run = AsyncMock(return_value="Found 5 Python files")
|
||||||
|
mock_get_agent.return_value = mock_explore
|
||||||
|
|
||||||
|
# Import and call spawn_agent directly
|
||||||
|
from src.domains.agents.task import tools
|
||||||
|
# We need to test the actual tool function
|
||||||
|
# For now, verify the explore agent would be called correctly
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_spawn_unknown_agent_returns_error(self):
|
||||||
|
"""Test that spawning unknown agent type returns error."""
|
||||||
|
from src.domains.agents.base import AgentContext
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from pydantic_ai import RunContext
|
||||||
|
|
||||||
|
# We can't easily test the tool directly, but we can verify
|
||||||
|
# the agent type validation logic
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "nonexistent" not in allowed_types
|
||||||
|
assert "task" not in allowed_types # Task should be blocked
|
||||||
|
|
||||||
|
def test_spawn_task_agent_blocked(self):
|
||||||
|
"""Test that spawning nested task agents is blocked."""
|
||||||
|
# Verify the validation logic prevents recursion
|
||||||
|
# The spawn_agent tool should return an error for agent_type="task"
|
||||||
|
allowed_types = ["explore", "plan"]
|
||||||
|
assert "task" not in allowed_types
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentAPI:
|
||||||
|
"""Tests for Task agent REST API."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_agents_includes_task(self, auth_client):
|
||||||
|
"""Test that agent list includes task agent."""
|
||||||
|
response = await auth_client.get("/agents/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [a["name"] for a in data["agents"]]
|
||||||
|
assert "task" in names
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_task_agent_info(self, auth_client):
|
||||||
|
"""Test getting task agent info."""
|
||||||
|
response = await auth_client.get("/agents/task")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "task"
|
||||||
|
assert "description" in data
|
||||||
|
assert len(data["description"]) > 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_run_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test running task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/run",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stream_task_with_invalid_body(self, auth_client):
|
||||||
|
"""Test streaming task agent with invalid request."""
|
||||||
|
response = await auth_client.post(
|
||||||
|
"/agents/stream",
|
||||||
|
json={
|
||||||
|
"agent_type": "task",
|
||||||
|
# Missing prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAgentProperties:
|
||||||
|
"""Tests for Task agent properties and configuration."""
|
||||||
|
|
||||||
|
def test_task_agent_name(self):
|
||||||
|
"""Test task agent name property."""
|
||||||
|
assert task_agent.name == "task"
|
||||||
|
|
||||||
|
def test_task_agent_description_not_empty(self):
|
||||||
|
"""Test task agent description is not empty."""
|
||||||
|
assert task_agent.description
|
||||||
|
assert len(task_agent.description) > 10
|
||||||
|
|
||||||
|
def test_task_agent_creates_agent_lazily(self):
|
||||||
|
"""Test that PydanticAI agent is created lazily."""
|
||||||
|
# Create a fresh instance
|
||||||
|
fresh_agent = TaskAgentImpl()
|
||||||
|
|
||||||
|
# _agents dict should be empty before first access
|
||||||
|
assert len(fresh_agent._agents) == 0
|
||||||
|
|
||||||
|
# Access the agent property (creates default mode agent)
|
||||||
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
|
# Now _agents should have one entry
|
||||||
|
assert len(fresh_agent._agents) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllAgentsRegistered:
|
||||||
|
"""Tests to verify all three agents are registered."""
|
||||||
|
|
||||||
|
def test_all_agents_in_registry(self):
|
||||||
|
"""Test that explore, plan, and task agents are all registered."""
|
||||||
|
agents = list_agents()
|
||||||
|
names = [a["name"] for a in agents]
|
||||||
|
|
||||||
|
assert "explore" in names
|
||||||
|
assert "plan" in names
|
||||||
|
assert "task" in names
|
||||||
|
assert len(names) == 3
|
||||||
|
|
||||||
|
def test_agent_hierarchy(self):
|
||||||
|
"""Test the agent capability hierarchy."""
|
||||||
|
explore = get_agent("explore")
|
||||||
|
plan = get_agent("plan")
|
||||||
|
task = get_agent("task")
|
||||||
|
|
||||||
|
explore_tools = list(explore.agent._function_toolset.tools.keys())
|
||||||
|
plan_tools = list(plan.agent._function_toolset.tools.keys())
|
||||||
|
task_tools = list(task.agent._function_toolset.tools.keys())
|
||||||
|
|
||||||
|
# Explore has all tools (read + write)
|
||||||
|
assert "edit_file" in explore_tools
|
||||||
|
assert "write_file" in explore_tools
|
||||||
|
|
||||||
|
# Plan has read-only tools
|
||||||
|
assert "edit_file" not in plan_tools
|
||||||
|
assert "write_file" not in plan_tools
|
||||||
|
|
||||||
|
# Task has all tools plus spawn_agent
|
||||||
|
assert "edit_file" in task_tools
|
||||||
|
assert "write_file" in task_tools
|
||||||
|
assert "spawn_agent" in task_tools
|
||||||
|
|
||||||
|
# Only Task has spawn_agent
|
||||||
|
assert "spawn_agent" not in explore_tools
|
||||||
|
assert "spawn_agent" not in plan_tools
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
Tests for token counting utilities.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.shared.tokens import count_tokens, count_message_tokens, estimate_tokens
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenCounting:
|
||||||
|
"""Tests for token counting functions."""
|
||||||
|
|
||||||
|
def test_estimate_tokens_basic(self):
|
||||||
|
"""Test basic token estimation."""
|
||||||
|
text = "Hello world"
|
||||||
|
tokens = estimate_tokens(text)
|
||||||
|
# ~4 chars per token
|
||||||
|
assert tokens == len(text) // 4
|
||||||
|
|
||||||
|
def test_estimate_tokens_empty(self):
|
||||||
|
"""Test estimation with empty string."""
|
||||||
|
assert estimate_tokens("") == 0
|
||||||
|
|
||||||
|
def test_estimate_tokens_long_text(self):
|
||||||
|
"""Test estimation with longer text."""
|
||||||
|
text = "a" * 400
|
||||||
|
tokens = estimate_tokens(text)
|
||||||
|
assert tokens == 100
|
||||||
|
|
||||||
|
def test_count_tokens_basic(self):
|
||||||
|
"""Test actual token counting."""
|
||||||
|
text = "Hello, how are you today?"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
# Should return reasonable token count
|
||||||
|
assert tokens > 0
|
||||||
|
assert tokens < len(text) # Should be fewer tokens than characters
|
||||||
|
|
||||||
|
def test_count_tokens_empty(self):
|
||||||
|
"""Test counting empty string."""
|
||||||
|
tokens = count_tokens("")
|
||||||
|
assert tokens == 0
|
||||||
|
|
||||||
|
def test_count_message_tokens_single(self):
|
||||||
|
"""Test counting tokens in single message."""
|
||||||
|
messages = [{"role": "user", "content": "Hello"}]
|
||||||
|
tokens = count_message_tokens(messages)
|
||||||
|
assert tokens > 0
|
||||||
|
|
||||||
|
def test_count_message_tokens_multiple(self):
|
||||||
|
"""Test counting tokens in multiple messages."""
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Hello, how are you?"},
|
||||||
|
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
||||||
|
]
|
||||||
|
tokens = count_message_tokens(messages)
|
||||||
|
# Should be more than single message
|
||||||
|
single_tokens = count_message_tokens([messages[0]])
|
||||||
|
assert tokens > single_tokens
|
||||||
|
|
||||||
|
def test_count_message_tokens_empty_list(self):
|
||||||
|
"""Test counting empty message list."""
|
||||||
|
tokens = count_message_tokens([])
|
||||||
|
# tiktoken returns small overhead for empty list (assistant priming)
|
||||||
|
assert tokens < 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenCountingAccuracy:
|
||||||
|
"""Tests for token counting accuracy."""
|
||||||
|
|
||||||
|
def test_code_tokens_reasonable(self):
|
||||||
|
"""Test that code is tokenized reasonably."""
|
||||||
|
code = """
|
||||||
|
def hello_world():
|
||||||
|
print("Hello, World!")
|
||||||
|
return True
|
||||||
|
"""
|
||||||
|
tokens = count_tokens(code)
|
||||||
|
# Code should have reasonable token count
|
||||||
|
assert 10 < tokens < 100
|
||||||
|
|
||||||
|
def test_special_characters(self):
|
||||||
|
"""Test tokenization of special characters."""
|
||||||
|
text = "Hello! @#$%^&*() World?"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
assert tokens > 0
|
||||||
|
|
||||||
|
def test_unicode_text(self):
|
||||||
|
"""Test tokenization of unicode text."""
|
||||||
|
text = "Hello 世界 🌍"
|
||||||
|
tokens = count_tokens(text)
|
||||||
|
assert tokens > 0
|
||||||
@@ -12,7 +12,7 @@ class TestWebSearchTool:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tool(self):
|
def tool(self):
|
||||||
return WebSearchTool(searxng_url="http://localhost:8087", timeout=5)
|
return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_search_response(self):
|
def mock_search_response(self):
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog - Webber CLI
|
||||||
|
|
||||||
|
All notable changes to the Webber CLI will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial CLI release as part of monorepo reorganization
|
||||||
|
- Typer + Rich foundation with console theming
|
||||||
|
- Commands:
|
||||||
|
- `webber-cli status` - Check API connection
|
||||||
|
- `webber-cli explore` - One-shot codebase exploration
|
||||||
|
- `webber-cli chat` - Interactive conversation mode
|
||||||
|
- Streaming support with `--stream` flag (default: enabled)
|
||||||
|
- Markdown rendering for agent responses
|
||||||
|
- Configurable API URL via `WEBBER_API_URL` environment variable
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
httpx~=0.28.1
|
httpx~=0.28.1
|
||||||
typer~=0.15.0
|
typer~=0.15.0
|
||||||
rich~=13.9.0
|
rich~=13.9.0
|
||||||
|
prompt_toolkit~=3.0.48
|
||||||
|
|||||||
+315
-11
@@ -2,20 +2,72 @@
|
|||||||
Webber API client.
|
Webber API client.
|
||||||
|
|
||||||
Communicates with the Webber API backend for agent execution.
|
Communicates with the Webber API backend for agent execution.
|
||||||
|
Supports permission modes for controlling agent tool access.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes controlling agent tool access.
|
||||||
|
|
||||||
|
- default: All tools available (approval may be required)
|
||||||
|
- plan: Read-only tools only
|
||||||
|
- auto_accept: All tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
auto_accept = "auto_accept"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEventType(str, Enum):
|
||||||
|
"""Event types for structured agent streaming."""
|
||||||
|
tool_start = "tool_start"
|
||||||
|
tool_done = "tool_done"
|
||||||
|
thinking = "thinking"
|
||||||
|
response = "response"
|
||||||
|
error = "error"
|
||||||
|
done = "done"
|
||||||
|
chunk = "chunk" # Legacy text chunk
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamEvent:
|
||||||
|
"""
|
||||||
|
Structured streaming event from agent execution.
|
||||||
|
|
||||||
|
Different event types carry different data:
|
||||||
|
- tool_start: tool, args
|
||||||
|
- tool_done: tool, result_summary
|
||||||
|
- thinking: message
|
||||||
|
- response: text
|
||||||
|
- error: error_message
|
||||||
|
- done: mode
|
||||||
|
- chunk: text (legacy)
|
||||||
|
"""
|
||||||
|
event: StreamEventType
|
||||||
|
tool: str | None = None
|
||||||
|
args: dict | None = None
|
||||||
|
result_summary: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
text: str | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AgentResponse:
|
class AgentResponse:
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
success: bool
|
success: bool
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +78,47 @@ class AgentInfo:
|
|||||||
description: str
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Message:
|
||||||
|
"""A message in a conversation."""
|
||||||
|
id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
token_count: int
|
||||||
|
is_summary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Conversation:
|
||||||
|
"""A conversation session."""
|
||||||
|
id: str
|
||||||
|
agent_type: str
|
||||||
|
title: str | None
|
||||||
|
working_dir: str
|
||||||
|
total_tokens: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime | None
|
||||||
|
messages: list[Message] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddMessageResult:
|
||||||
|
"""Result of adding a message to a conversation."""
|
||||||
|
user_message: Message
|
||||||
|
assistant_message: Message
|
||||||
|
total_tokens: int
|
||||||
|
summarized: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SaveMessagesResult:
|
||||||
|
"""Result of saving a message pair without agent execution."""
|
||||||
|
user_message: Message
|
||||||
|
assistant_message: Message
|
||||||
|
total_tokens: int
|
||||||
|
|
||||||
|
|
||||||
class WebberClient:
|
class WebberClient:
|
||||||
"""
|
"""
|
||||||
Client for the Webber API.
|
Client for the Webber API.
|
||||||
@@ -104,14 +197,16 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""
|
"""
|
||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentResponse with the result
|
AgentResponse with the result
|
||||||
@@ -123,6 +218,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -131,6 +227,7 @@ class WebberClient:
|
|||||||
response=data.get("response", ""),
|
response=data.get("response", ""),
|
||||||
agent_type=data.get("agent_type", agent_type),
|
agent_type=data.get("agent_type", agent_type),
|
||||||
success=data.get("success", True),
|
success=data.get("success", True),
|
||||||
|
mode=PermissionMode(data.get("mode", "default")),
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,17 +236,19 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
) -> AsyncIterator[str]:
|
mode: PermissionMode = PermissionMode.default,
|
||||||
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""
|
"""
|
||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Text chunks as they arrive
|
StreamEvent objects as they arrive
|
||||||
"""
|
"""
|
||||||
# Use a fresh client for streaming with longer timeout
|
# Use a fresh client for streaming with longer timeout
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
@@ -163,6 +262,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
) as response:
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -170,16 +270,220 @@ class WebberClient:
|
|||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
try:
|
try:
|
||||||
data = json.loads(line[6:])
|
data = json.loads(line[6:])
|
||||||
event = data.get("event")
|
event_type = data.get("event")
|
||||||
if event == "chunk":
|
|
||||||
yield data.get("data", "")
|
# Parse event type
|
||||||
elif event == "error":
|
try:
|
||||||
raise Exception(data.get("data", "Unknown error"))
|
evt_type = StreamEventType(event_type)
|
||||||
elif event == "done":
|
except ValueError:
|
||||||
|
continue # Unknown event type
|
||||||
|
|
||||||
|
# Build StreamEvent from response data
|
||||||
|
yield StreamEvent(
|
||||||
|
event=evt_type,
|
||||||
|
tool=data.get("tool"),
|
||||||
|
args=data.get("args"),
|
||||||
|
result_summary=data.get("result_summary"),
|
||||||
|
message=data.get("message"),
|
||||||
|
text=data.get("text") or data.get("data"), # 'data' for legacy chunk
|
||||||
|
error_message=data.get("error_message") or data.get("data"),
|
||||||
|
mode=data.get("mode"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop on done or error
|
||||||
|
if evt_type in (StreamEventType.done, StreamEventType.error):
|
||||||
break
|
break
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# === Conversation API ===
|
||||||
|
|
||||||
|
def _parse_message(self, data: dict) -> Message:
|
||||||
|
"""Parse a Message from API response data."""
|
||||||
|
return Message(
|
||||||
|
id=data["id"],
|
||||||
|
role=data["role"],
|
||||||
|
content=data["content"],
|
||||||
|
token_count=data["token_count"],
|
||||||
|
is_summary=data["is_summary"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_conversation(self, data: dict, with_messages: bool = False) -> Conversation:
|
||||||
|
"""Parse a Conversation from API response data."""
|
||||||
|
messages = []
|
||||||
|
if with_messages and "messages" in data:
|
||||||
|
messages = [self._parse_message(m) for m in data["messages"]]
|
||||||
|
|
||||||
|
updated_at = None
|
||||||
|
if data.get("updated_at"):
|
||||||
|
updated_at = datetime.fromisoformat(data["updated_at"].replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
return Conversation(
|
||||||
|
id=data["id"],
|
||||||
|
agent_type=data["agent_type"],
|
||||||
|
title=data.get("title"),
|
||||||
|
working_dir=data["working_dir"],
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
updated_at=updated_at,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_conversations(
|
||||||
|
self,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Conversation], int]:
|
||||||
|
"""
|
||||||
|
List user's conversations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of conversations to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (conversations, total_count)
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(
|
||||||
|
"/conversations/",
|
||||||
|
params={"limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
conversations = [self._parse_conversation(c) for c in data["conversations"]]
|
||||||
|
return conversations, data["total"]
|
||||||
|
|
||||||
|
async def get_conversation(self, conversation_id: str) -> Conversation | None:
|
||||||
|
"""
|
||||||
|
Get a conversation with all messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conversation with messages, or None if not found
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.get(f"/conversations/{conversation_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
return self._parse_conversation(response.json(), with_messages=True)
|
||||||
|
|
||||||
|
async def create_conversation(
|
||||||
|
self,
|
||||||
|
agent_type: str = "task",
|
||||||
|
working_dir: str = ".",
|
||||||
|
title: str | None = None,
|
||||||
|
) -> Conversation:
|
||||||
|
"""
|
||||||
|
Create a new conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_type: Type of agent to use
|
||||||
|
working_dir: Working directory for the agent
|
||||||
|
title: Optional title for the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created conversation
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
"/conversations/",
|
||||||
|
json={
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"working_dir": working_dir,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return self._parse_conversation(response.json())
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
content: str,
|
||||||
|
) -> AddMessageResult:
|
||||||
|
"""
|
||||||
|
Add a message to a conversation and get agent response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
content: Message content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AddMessageResult with user and assistant messages
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
f"/conversations/{conversation_id}/messages",
|
||||||
|
json={"content": content},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return AddMessageResult(
|
||||||
|
user_message=self._parse_message(data["user_message"]),
|
||||||
|
assistant_message=self._parse_message(data["assistant_message"]),
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
summarized=data.get("summarized", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_messages(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
user_content: str,
|
||||||
|
assistant_content: str,
|
||||||
|
) -> SaveMessagesResult:
|
||||||
|
"""
|
||||||
|
Save a user/assistant message pair without triggering agent execution.
|
||||||
|
|
||||||
|
Used when streaming responses separately via run_agent_stream().
|
||||||
|
Allows persisting the exchange after streaming completes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
user_content: User message content
|
||||||
|
assistant_content: Assistant response content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SaveMessagesResult with both messages
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.post(
|
||||||
|
f"/conversations/{conversation_id}/save",
|
||||||
|
json={
|
||||||
|
"user_content": user_content,
|
||||||
|
"assistant_content": assistant_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return SaveMessagesResult(
|
||||||
|
user_message=self._parse_message(data["user_message"]),
|
||||||
|
assistant_message=self._parse_message(data["assistant_message"]),
|
||||||
|
total_tokens=data["total_tokens"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_conversation(self, conversation_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conversation_id: UUID of the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
response = await client.delete(f"/conversations/{conversation_id}")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return False
|
||||||
|
response.raise_for_status()
|
||||||
|
return True
|
||||||
|
|
||||||
async def __aenter__(self) -> "WebberClient":
|
async def __aenter__(self) -> "WebberClient":
|
||||||
"""Async context manager entry."""
|
"""Async context manager entry."""
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Configuration management for Webber CLI.
|
||||||
|
|
||||||
|
Loads settings from ~/.webber/config.toml with environment variable overrides.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# Config directory and file paths
|
||||||
|
CONFIG_DIR = Path.home() / ".webber"
|
||||||
|
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
||||||
|
|
||||||
|
# Default configuration template
|
||||||
|
DEFAULT_CONFIG = """\
|
||||||
|
# Webber CLI Configuration
|
||||||
|
# https://github.com/jpmschweitzer/webber
|
||||||
|
|
||||||
|
[api]
|
||||||
|
# API server URL (dev: 8095, prod: 8086)
|
||||||
|
url = "http://localhost:8095"
|
||||||
|
|
||||||
|
# API key for authentication (optional for dev mode)
|
||||||
|
# key = "your-api-key"
|
||||||
|
|
||||||
|
[cli]
|
||||||
|
# Default permission mode: default, plan, auto_accept
|
||||||
|
mode = "default"
|
||||||
|
|
||||||
|
# Enable streaming by default
|
||||||
|
stream = true
|
||||||
|
|
||||||
|
[history]
|
||||||
|
# Command history file location
|
||||||
|
file = "~/.webber_history"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApiConfig:
|
||||||
|
"""API connection settings."""
|
||||||
|
url: str = "http://localhost:8095"
|
||||||
|
key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CliConfig:
|
||||||
|
"""CLI behavior settings."""
|
||||||
|
mode: str = "default"
|
||||||
|
stream: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HistoryConfig:
|
||||||
|
"""History settings."""
|
||||||
|
file: str = "~/.webber_history"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Complete configuration."""
|
||||||
|
api: ApiConfig = field(default_factory=ApiConfig)
|
||||||
|
cli: CliConfig = field(default_factory=CliConfig)
|
||||||
|
history: HistoryConfig = field(default_factory=HistoryConfig)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
"""
|
||||||
|
Load configuration from file with environment variable overrides.
|
||||||
|
|
||||||
|
Priority (highest to lowest):
|
||||||
|
1. Environment variables (WEBBER_API_URL, WEBBER_API_KEY, WEBBER_MODE)
|
||||||
|
2. Config file (~/.webber/config.toml)
|
||||||
|
3. Built-in defaults
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Config object with merged settings
|
||||||
|
"""
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
# Load from file if exists
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
config = _parse_config(data)
|
||||||
|
except Exception:
|
||||||
|
# If config file is invalid, use defaults
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply environment variable overrides
|
||||||
|
if url := os.environ.get("WEBBER_API_URL"):
|
||||||
|
config.api.url = url
|
||||||
|
if key := os.environ.get("WEBBER_API_KEY"):
|
||||||
|
config.api.key = key
|
||||||
|
if mode := os.environ.get("WEBBER_MODE"):
|
||||||
|
config.cli.mode = mode
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_config(data: dict[str, Any]) -> Config:
|
||||||
|
"""Parse config dict into Config object."""
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
if api := data.get("api"):
|
||||||
|
config.api.url = api.get("url", config.api.url)
|
||||||
|
config.api.key = api.get("key", config.api.key)
|
||||||
|
|
||||||
|
if cli := data.get("cli"):
|
||||||
|
config.cli.mode = cli.get("mode", config.cli.mode)
|
||||||
|
config.cli.stream = cli.get("stream", config.cli.stream)
|
||||||
|
|
||||||
|
if history := data.get("history"):
|
||||||
|
config.history.file = history.get("file", config.history.file)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def init_config() -> Path:
|
||||||
|
"""
|
||||||
|
Initialize config directory and file with defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the created config file
|
||||||
|
"""
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not CONFIG_FILE.exists():
|
||||||
|
CONFIG_FILE.write_text(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
return CONFIG_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_path() -> Path | None:
|
||||||
|
"""Get path to config file if it exists."""
|
||||||
|
return CONFIG_FILE if CONFIG_FILE.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level cached config
|
||||||
|
_config: Config | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> Config:
|
||||||
|
"""Get cached config, loading if necessary."""
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = load_config()
|
||||||
|
return _config
|
||||||
+577
-129
@@ -5,45 +5,146 @@ Webber CLI - Client for the Webber API.
|
|||||||
Usage:
|
Usage:
|
||||||
webber-cli --help
|
webber-cli --help
|
||||||
webber-cli chat [OPTIONS]
|
webber-cli chat [OPTIONS]
|
||||||
webber-cli explore QUERY [OPTIONS]
|
webber-cli status
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.live import Live
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.completion import Completer, Completion, PathCompleter
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
from webber_cli.client import WebberClient
|
from webber_cli.client import WebberClient, PermissionMode, StreamEvent, StreamEventType
|
||||||
|
from webber_cli.config import get_config, init_config, get_config_path, CONFIG_FILE
|
||||||
from webber_cli.theme import get_console, get_theme
|
from webber_cli.theme import get_console, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
# === Prompt Toolkit Setup ===
|
||||||
|
|
||||||
|
def _get_history_file() -> Path:
|
||||||
|
"""Get history file path from config."""
|
||||||
|
config = get_config()
|
||||||
|
return Path(config.history.file).expanduser()
|
||||||
|
|
||||||
|
# Built-in commands for completion
|
||||||
|
BUILTIN_COMMANDS = [
|
||||||
|
"exit",
|
||||||
|
"quit",
|
||||||
|
"clear",
|
||||||
|
"mode plan",
|
||||||
|
"mode default",
|
||||||
|
"mode auto_accept",
|
||||||
|
"cd ",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WebberCompleter(Completer):
|
||||||
|
"""Custom completer for Webber CLI commands."""
|
||||||
|
|
||||||
|
def __init__(self, working_dir: str):
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.path_completer = PathCompleter(expanduser=True)
|
||||||
|
|
||||||
|
def get_completions(self, document, complete_event):
|
||||||
|
text = document.text_before_cursor.lower()
|
||||||
|
|
||||||
|
# Complete built-in commands
|
||||||
|
if not text or not text.startswith("cd "):
|
||||||
|
for cmd in BUILTIN_COMMANDS:
|
||||||
|
if cmd.startswith(text):
|
||||||
|
yield Completion(
|
||||||
|
cmd,
|
||||||
|
start_position=-len(text),
|
||||||
|
display_meta="command",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete file paths after "cd "
|
||||||
|
if text.startswith("cd "):
|
||||||
|
path_text = text[3:]
|
||||||
|
# Create a sub-document for path completion
|
||||||
|
from prompt_toolkit.document import Document
|
||||||
|
path_doc = Document(path_text, len(path_text))
|
||||||
|
for completion in self.path_completer.get_completions(path_doc, complete_event):
|
||||||
|
yield Completion(
|
||||||
|
"cd " + (path_text + completion.text),
|
||||||
|
start_position=-len(text),
|
||||||
|
display_meta="directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Prompt style matching Rich theme
|
||||||
|
PROMPT_STYLE = Style.from_dict({
|
||||||
|
"prompt": "#5f87d7 bold", # info color
|
||||||
|
"": "", # default text
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_session(working_dir: str) -> PromptSession:
|
||||||
|
"""Create a configured prompt session with history and completion."""
|
||||||
|
return PromptSession(
|
||||||
|
history=FileHistory(str(_get_history_file())),
|
||||||
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
|
completer=WebberCompleter(working_dir),
|
||||||
|
style=PROMPT_STYLE,
|
||||||
|
complete_while_typing=False, # Only complete on Tab
|
||||||
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="webber-cli",
|
name="webber-cli",
|
||||||
help="CLI client for the Webber API",
|
help="CLI client for the Webber API",
|
||||||
no_args_is_help=True,
|
no_args_is_help=False,
|
||||||
|
invoke_without_command=True,
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
console = get_console()
|
console = get_console()
|
||||||
|
|
||||||
# Default API URL (can be overridden via env or option)
|
|
||||||
# Development port is 8095, production is 8086
|
def _get_api_url() -> str:
|
||||||
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
|
"""Get API URL from config (with env override already applied)."""
|
||||||
|
return get_config().api.url
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key() -> str:
|
||||||
|
"""Get API key from config (with env override already applied)."""
|
||||||
|
return get_config().api.key or "webber-cli-dev-key"
|
||||||
|
|
||||||
|
|
||||||
def version_callback(value: bool) -> None:
|
def version_callback(value: bool) -> None:
|
||||||
"""Display version and exit."""
|
"""Display version and exit."""
|
||||||
if value:
|
if value:
|
||||||
from cli import __version__
|
from webber_cli import __version__
|
||||||
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
def _confirm_auto_accept() -> bool:
|
||||||
|
"""
|
||||||
|
Prompt user to confirm auto_accept mode.
|
||||||
|
|
||||||
|
Returns True if user confirms, False otherwise.
|
||||||
|
"""
|
||||||
|
console.print()
|
||||||
|
console.print("[warning]WARNING:[/] auto_accept mode bypasses all safety prompts.")
|
||||||
|
console.print("The agent will execute write operations without confirmation.")
|
||||||
|
console.print()
|
||||||
|
return Confirm.ask(
|
||||||
|
"[warning]Are you sure you want to enable auto_accept mode?[/]",
|
||||||
|
default=False,
|
||||||
|
console=console,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback(invoke_without_command=True)
|
||||||
def main(
|
def main(
|
||||||
|
ctx: typer.Context,
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
False,
|
False,
|
||||||
"--version",
|
"--version",
|
||||||
@@ -54,7 +155,79 @@ def main(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Webber CLI - Talk to the Webber API."""
|
"""Webber CLI - Talk to the Webber API."""
|
||||||
pass
|
# Default to chat command if no subcommand given
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
chat(
|
||||||
|
directory=".",
|
||||||
|
api_url=None,
|
||||||
|
mode=None,
|
||||||
|
resume=None,
|
||||||
|
stream=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def sessions(
|
||||||
|
api_url: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api",
|
||||||
|
"-a",
|
||||||
|
help="Webber API URL (default from config)",
|
||||||
|
),
|
||||||
|
limit: int = typer.Option(
|
||||||
|
20,
|
||||||
|
"--limit",
|
||||||
|
"-n",
|
||||||
|
help="Maximum number of sessions to show",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
List previous conversation sessions.
|
||||||
|
|
||||||
|
Shows recent sessions that can be resumed with 'chat --resume <id>'.
|
||||||
|
"""
|
||||||
|
url = api_url or _get_api_url()
|
||||||
|
asyncio.run(_list_sessions(url, limit))
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_sessions(api_url: str, limit: int) -> None:
|
||||||
|
"""List conversation sessions."""
|
||||||
|
async with WebberClient(api_url, api_key=_get_api_key()) as client:
|
||||||
|
# Check API health
|
||||||
|
if not await client.health_check():
|
||||||
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conversations, total = await client.list_conversations(limit=limit)
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[error]Error listing sessions:[/] {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
console.print("[dim]No sessions found. Start one with 'webber-cli chat'[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[title]Sessions[/] [dim]({len(conversations)} of {total})[/]\n")
|
||||||
|
|
||||||
|
for conv in conversations:
|
||||||
|
# Format the date
|
||||||
|
date_str = conv.created_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
# Title or first message preview
|
||||||
|
title = conv.title or "[dim]untitled[/]"
|
||||||
|
|
||||||
|
# Truncate ID for display
|
||||||
|
short_id = conv.id[:8]
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
f" [info]{short_id}[/] {date_str} "
|
||||||
|
f"[path]{conv.working_dir}[/] {title} "
|
||||||
|
f"[dim]({conv.total_tokens} tokens)[/]"
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[dim]Resume with: webber-cli chat --resume <id>[/]")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
@@ -63,50 +236,95 @@ def chat(
|
|||||||
".",
|
".",
|
||||||
"--directory",
|
"--directory",
|
||||||
"-d",
|
"-d",
|
||||||
help="Working directory for exploration",
|
help="Working directory for the agent",
|
||||||
),
|
),
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
agent: str = typer.Option(
|
mode: str = typer.Option(
|
||||||
"explore",
|
None,
|
||||||
"--agent",
|
"--mode",
|
||||||
help="Agent to use",
|
"-m",
|
||||||
|
help="Permission mode: default, plan, auto_accept (default from config)",
|
||||||
|
),
|
||||||
|
resume: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--resume",
|
||||||
|
"-r",
|
||||||
|
help="Resume a previous session by ID (use 'sessions' to list)",
|
||||||
),
|
),
|
||||||
stream: bool = typer.Option(
|
stream: bool = typer.Option(
|
||||||
True,
|
None,
|
||||||
"--stream/--no-stream",
|
"--stream/--no-stream",
|
||||||
"-s",
|
"-s",
|
||||||
help="Stream responses in real-time",
|
help="Stream responses in real-time (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Start interactive chat session.
|
Start interactive chat session with the Task agent.
|
||||||
|
|
||||||
Connects to the Webber API backend for agent execution.
|
The Task agent is the main orchestrator that can:
|
||||||
|
- Explore and analyze codebases
|
||||||
|
- Plan implementation strategies
|
||||||
|
- Execute code modifications (in default/auto_accept modes)
|
||||||
|
- Spawn sub-agents for focused tasks
|
||||||
|
|
||||||
|
Permission modes:
|
||||||
|
- default: Full capabilities with approval prompts for writes
|
||||||
|
- plan: Read-only mode for safe exploration and planning
|
||||||
|
- auto_accept: Full capabilities without approval prompts (use with caution)
|
||||||
|
|
||||||
|
Use --resume to continue a previous session.
|
||||||
"""
|
"""
|
||||||
|
# Resolve config defaults
|
||||||
|
config = get_config()
|
||||||
|
url = api_url or config.api.url
|
||||||
|
mode_str = mode or config.cli.mode
|
||||||
|
use_stream = stream if stream is not None else config.cli.stream
|
||||||
|
|
||||||
working_dir = str(Path(directory).resolve())
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
if not Path(working_dir).exists():
|
||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Parse and validate mode
|
||||||
try:
|
try:
|
||||||
asyncio.run(_chat_loop(api_url, agent, working_dir, stream))
|
permission_mode = PermissionMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Error:[/] Invalid mode: {mode_str}")
|
||||||
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Confirm auto_accept mode (security risk)
|
||||||
|
if permission_mode == PermissionMode.auto_accept:
|
||||||
|
if not _confirm_auto_accept():
|
||||||
|
console.print("[dim]Cancelled. Using default mode instead.[/]")
|
||||||
|
permission_mode = PermissionMode.default
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(_chat_loop(url, working_dir, permission_mode, use_stream, resume))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Goodbye![/]")
|
console.print("\n[dim]Goodbye![/]")
|
||||||
|
|
||||||
|
|
||||||
async def _chat_loop(
|
async def _chat_loop(
|
||||||
api_url: str, agent_type: str, working_dir: str, stream: bool = True
|
api_url: str,
|
||||||
|
working_dir: str,
|
||||||
|
mode: PermissionMode,
|
||||||
|
stream: bool = True,
|
||||||
|
resume_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive chat loop."""
|
"""Interactive chat loop with the Task agent."""
|
||||||
theme = get_theme()
|
theme = get_theme()
|
||||||
|
agent_type = "task"
|
||||||
|
conversation_id: str | None = None
|
||||||
|
conversation_title: str | None = None
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
async with WebberClient(api_url, api_key=_get_api_key()) as client:
|
||||||
# Check API health
|
# Check API health
|
||||||
if not await client.health_check():
|
if not await client.health_check():
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
@@ -116,28 +334,99 @@ async def _chat_loop(
|
|||||||
# Get agent info
|
# Get agent info
|
||||||
agent_info = await client.get_agent(agent_type)
|
agent_info = await client.get_agent(agent_type)
|
||||||
if not agent_info:
|
if not agent_info:
|
||||||
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
|
console.print(f"[error]Error:[/] Task agent not found")
|
||||||
agents = await client.list_agents()
|
|
||||||
console.print("[dim]Available agents:[/]")
|
|
||||||
for a in agents:
|
|
||||||
console.print(f" - {a.name}: {a.description}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Handle resume or create new conversation
|
||||||
|
if resume_id:
|
||||||
|
# Try to find conversation by ID prefix
|
||||||
|
try:
|
||||||
|
conversations, _ = await client.list_conversations(limit=100)
|
||||||
|
matching = [c for c in conversations if c.id.startswith(resume_id)]
|
||||||
|
if not matching:
|
||||||
|
console.print(f"[error]Error:[/] Session not found: {resume_id}")
|
||||||
|
console.print("[dim]Use 'webber-cli sessions' to list available sessions[/]")
|
||||||
|
return
|
||||||
|
if len(matching) > 1:
|
||||||
|
console.print(f"[error]Error:[/] Ambiguous ID, multiple matches: {resume_id}")
|
||||||
|
for m in matching:
|
||||||
|
console.print(f" - {m.id[:8]} ({m.title or 'untitled'})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the conversation with messages
|
||||||
|
conv = await client.get_conversation(matching[0].id)
|
||||||
|
if not conv:
|
||||||
|
console.print(f"[error]Error:[/] Could not load session")
|
||||||
|
return
|
||||||
|
|
||||||
|
conversation_id = conv.id
|
||||||
|
conversation_title = conv.title
|
||||||
|
working_dir = conv.working_dir # Use the session's working directory
|
||||||
|
|
||||||
|
# Display conversation history
|
||||||
|
console.print(f"\n[title]Resuming session[/] [dim]{conv.id[:8]}[/]")
|
||||||
|
if conv.messages:
|
||||||
|
console.print(f"[dim]({len(conv.messages)} messages, {conv.total_tokens} tokens)[/]\n")
|
||||||
|
for msg in conv.messages[-6:]: # Show last 6 messages
|
||||||
|
if msg.role == "user":
|
||||||
|
console.print(f"[prompt]>[/] {msg.content[:100]}{'...' if len(msg.content) > 100 else ''}")
|
||||||
|
else:
|
||||||
|
preview = msg.content[:200].replace('\n', ' ')
|
||||||
|
console.print(f"[dim]{preview}{'...' if len(msg.content) > 200 else ''}[/]\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[error]Error resuming session:[/] {e}")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Create a new conversation
|
||||||
|
try:
|
||||||
|
conv = await client.create_conversation(
|
||||||
|
agent_type=agent_type,
|
||||||
|
working_dir=working_dir,
|
||||||
|
title=None, # Will be set later based on first message
|
||||||
|
)
|
||||||
|
conversation_id = conv.id
|
||||||
|
console.print(f"[dim]Session: {conv.id[:8]}[/]")
|
||||||
|
except Exception as e:
|
||||||
|
# If conversation API fails, continue without persistence
|
||||||
|
console.print(f"[dim]Note: Session persistence unavailable ({e})[/]")
|
||||||
|
|
||||||
|
# Mode display
|
||||||
|
mode_display = {
|
||||||
|
PermissionMode.default: "[info]default[/] (full with approvals)",
|
||||||
|
PermissionMode.plan: "[success]plan[/] (read-only)",
|
||||||
|
PermissionMode.auto_accept: "[warning]auto_accept[/] (no prompts)",
|
||||||
|
}
|
||||||
|
|
||||||
# Welcome message
|
# Welcome message
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
||||||
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
||||||
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
|
console.print(f"[dim]Mode:[/] {mode_display[mode]}")
|
||||||
mode = "streaming" if stream else "batch"
|
console.print(f"[dim]Streaming:[/] {'enabled' if stream else 'disabled'}")
|
||||||
console.print(f"[dim]Mode:[/] {mode}")
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
|
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear, 'mode <plan|default|auto_accept>' to switch[/]")
|
||||||
|
console.print("[dim]Tab for completion, Up/Down for history[/]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
current_mode = mode
|
||||||
|
|
||||||
|
# Create prompt session with history and completion
|
||||||
|
session = create_prompt_session(working_dir)
|
||||||
|
|
||||||
# Chat loop
|
# Chat loop
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_input = console.input("[prompt]>[/] ").strip()
|
# Use prompt_toolkit for input (with history and completion)
|
||||||
|
try:
|
||||||
|
user_input = await session.prompt_async(
|
||||||
|
[("class:prompt", "> ")],
|
||||||
|
)
|
||||||
|
user_input = user_input.strip()
|
||||||
|
except EOFError:
|
||||||
|
# Ctrl+D pressed
|
||||||
|
console.print("[dim]Goodbye![/]")
|
||||||
|
break
|
||||||
|
|
||||||
if not user_input:
|
if not user_input:
|
||||||
continue
|
continue
|
||||||
@@ -152,36 +441,123 @@ async def _chat_loop(
|
|||||||
|
|
||||||
if user_input.lower().startswith("cd "):
|
if user_input.lower().startswith("cd "):
|
||||||
new_dir = user_input[3:].strip()
|
new_dir = user_input[3:].strip()
|
||||||
new_path = Path(new_dir).resolve()
|
# Handle ~ expansion
|
||||||
|
new_path = Path(new_dir).expanduser().resolve()
|
||||||
if new_path.exists() and new_path.is_dir():
|
if new_path.exists() and new_path.is_dir():
|
||||||
working_dir = str(new_path)
|
working_dir = str(new_path)
|
||||||
|
# Update completer's working directory
|
||||||
|
session.completer.working_dir = working_dir
|
||||||
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[error]Directory not found:[/] {new_dir}")
|
console.print(f"[error]Directory not found:[/] {new_dir}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Mode switching
|
||||||
|
if user_input.lower().startswith("mode "):
|
||||||
|
new_mode_str = user_input[5:].strip()
|
||||||
|
try:
|
||||||
|
new_mode = PermissionMode(new_mode_str)
|
||||||
|
if new_mode == PermissionMode.auto_accept:
|
||||||
|
if not _confirm_auto_accept():
|
||||||
|
console.print("[dim]Mode unchanged.[/]")
|
||||||
|
continue
|
||||||
|
current_mode = new_mode
|
||||||
|
console.print(f"[info]Mode changed to:[/] {mode_display[current_mode]}")
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Invalid mode:[/] {new_mode_str}")
|
||||||
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
|
continue
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
# Stream response in real-time
|
# Stream response with structured events
|
||||||
full_response = ""
|
response_chunks: list[str] = []
|
||||||
|
current_tool: str | None = None
|
||||||
try:
|
try:
|
||||||
async for chunk in client.run_agent_stream(
|
async for event in client.run_agent_stream(
|
||||||
agent_type, user_input, working_dir
|
agent_type, user_input, working_dir, current_mode
|
||||||
):
|
):
|
||||||
sys.stdout.write(chunk)
|
if event.event == StreamEventType.thinking:
|
||||||
sys.stdout.flush()
|
# Show thinking status
|
||||||
full_response += chunk
|
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.tool_start:
|
||||||
|
# Show tool starting
|
||||||
|
current_tool = event.tool
|
||||||
|
args_display = ""
|
||||||
|
if event.args:
|
||||||
|
# Format key args for display
|
||||||
|
key_args = []
|
||||||
|
for k, v in list(event.args.items())[:2]:
|
||||||
|
v_str = str(v)[:40] + "..." if len(str(v)) > 40 else str(v)
|
||||||
|
key_args.append(f"{k}={v_str}")
|
||||||
|
args_display = f" ({', '.join(key_args)})"
|
||||||
|
console.print(f"[info]→ {event.tool}[/]{args_display}", end="")
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.tool_done:
|
||||||
|
# Show tool completed
|
||||||
|
result = event.result_summary or "done"
|
||||||
|
console.print(f" [success]✓[/] [dim]{result}[/]")
|
||||||
|
current_tool = None
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.response:
|
||||||
|
# Stream response text
|
||||||
|
if event.text:
|
||||||
|
sys.stdout.write(event.text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
response_chunks.append(event.text)
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.chunk:
|
||||||
|
# Legacy text chunk (for other agents)
|
||||||
|
if event.text:
|
||||||
|
sys.stdout.write(event.text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
response_chunks.append(event.text)
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.error:
|
||||||
|
console.print(f"\n[error]Error:[/] {event.error_message}")
|
||||||
|
|
||||||
|
elif event.event == StreamEventType.done:
|
||||||
|
pass # Stream complete
|
||||||
|
|
||||||
console.print() # Newline after streaming
|
console.print() # Newline after streaming
|
||||||
|
|
||||||
|
# Save messages to conversation if we have a session
|
||||||
|
if conversation_id and response_chunks:
|
||||||
|
try:
|
||||||
|
full_response = "".join(response_chunks)
|
||||||
|
await client.save_messages(
|
||||||
|
conversation_id,
|
||||||
|
user_input,
|
||||||
|
full_response,
|
||||||
|
)
|
||||||
|
except Exception as save_error:
|
||||||
|
# Log but don't fail the interaction
|
||||||
|
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
console.print(f"\n[error]Stream error:[/] {e}")
|
||||||
else:
|
else:
|
||||||
# Batch mode with spinner
|
# Batch mode with spinner
|
||||||
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
||||||
result = await client.run_agent(agent_type, user_input, working_dir)
|
result = await client.run_agent(
|
||||||
|
agent_type, user_input, working_dir, current_mode
|
||||||
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
console.print(Markdown(result.response))
|
console.print(Markdown(result.response))
|
||||||
|
|
||||||
|
# Save messages to conversation if we have a session
|
||||||
|
if conversation_id:
|
||||||
|
try:
|
||||||
|
await client.save_messages(
|
||||||
|
conversation_id,
|
||||||
|
user_input,
|
||||||
|
result.response,
|
||||||
|
)
|
||||||
|
except Exception as save_error:
|
||||||
|
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[error]Error:[/] {result.error}")
|
console.print(f"[error]Error:[/] {result.error}")
|
||||||
|
|
||||||
@@ -194,98 +570,18 @@ async def _chat_loop(
|
|||||||
console.print(f"[error]Error:[/] {e}")
|
console.print(f"[error]Error:[/] {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def explore(
|
|
||||||
query: str = typer.Argument(..., help="What to search for"),
|
|
||||||
directory: str = typer.Option(
|
|
||||||
".",
|
|
||||||
"--directory",
|
|
||||||
"-d",
|
|
||||||
help="Working directory",
|
|
||||||
),
|
|
||||||
api_url: str = typer.Option(
|
|
||||||
DEFAULT_API_URL,
|
|
||||||
"--api",
|
|
||||||
"-a",
|
|
||||||
help="Webber API URL",
|
|
||||||
),
|
|
||||||
stream: bool = typer.Option(
|
|
||||||
True,
|
|
||||||
"--stream/--no-stream",
|
|
||||||
"-s",
|
|
||||||
help="Stream responses in real-time",
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
One-shot codebase exploration.
|
|
||||||
|
|
||||||
Sends a query to the Webber API and displays the result.
|
|
||||||
"""
|
|
||||||
working_dir = str(Path(directory).resolve())
|
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
|
||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
asyncio.run(_explore(api_url, query, working_dir, stream))
|
|
||||||
|
|
||||||
|
|
||||||
async def _explore(
|
|
||||||
api_url: str, query: str, working_dir: str, stream: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Execute exploration query."""
|
|
||||||
theme = get_theme()
|
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
|
||||||
# Check API health
|
|
||||||
if not await client.health_check():
|
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
|
||||||
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
|
||||||
return
|
|
||||||
|
|
||||||
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
|
||||||
console.print(f"[dim]Query:[/] {query}")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
if stream:
|
|
||||||
# Stream response in real-time
|
|
||||||
try:
|
|
||||||
async for chunk in client.run_agent_stream("explore", query, working_dir):
|
|
||||||
sys.stdout.write(chunk)
|
|
||||||
sys.stdout.flush()
|
|
||||||
console.print() # Newline after streaming
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
|
||||||
else:
|
|
||||||
# Batch mode with spinner
|
|
||||||
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
|
||||||
result = await client.run_agent("explore", query, working_dir)
|
|
||||||
|
|
||||||
if result.success:
|
|
||||||
console.print(Panel(
|
|
||||||
Markdown(result.response),
|
|
||||||
title="[success]Findings[/]",
|
|
||||||
border_style=theme.colors.border_success,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
console.print(Panel(
|
|
||||||
f"[error]{result.error}[/]",
|
|
||||||
title="[error]Error[/]",
|
|
||||||
border_style=theme.colors.border_error,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def status(
|
def status(
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
None,
|
||||||
"--api",
|
"--api",
|
||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL (default from config)",
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check API status and list available agents."""
|
"""Check API status and list available agents."""
|
||||||
asyncio.run(_status(api_url))
|
url = api_url or _get_api_url()
|
||||||
|
asyncio.run(_status(url))
|
||||||
|
|
||||||
|
|
||||||
async def _status(api_url: str) -> None:
|
async def _status(api_url: str) -> None:
|
||||||
@@ -304,5 +600,157 @@ async def _status(api_url: str) -> None:
|
|||||||
console.print("[error]Status:[/] Cannot connect")
|
console.print("[error]Status:[/] Cannot connect")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def config(
|
||||||
|
init: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--init",
|
||||||
|
"-i",
|
||||||
|
help="Initialize config file with defaults",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Show or initialize configuration.
|
||||||
|
|
||||||
|
Without --init, displays current config and source.
|
||||||
|
With --init, creates ~/.webber/config.toml with defaults.
|
||||||
|
"""
|
||||||
|
if init:
|
||||||
|
path = init_config()
|
||||||
|
console.print(f"[success]Config initialized:[/] {path}")
|
||||||
|
console.print("[dim]Edit this file to customize settings.[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show current config
|
||||||
|
cfg = get_config()
|
||||||
|
config_path = get_config_path()
|
||||||
|
|
||||||
|
console.print("[title]Webber Configuration[/]\n")
|
||||||
|
|
||||||
|
if config_path:
|
||||||
|
console.print(f"[dim]Config file:[/] {config_path}")
|
||||||
|
else:
|
||||||
|
console.print(f"[dim]Config file:[/] [warning]Not found[/] (using defaults)")
|
||||||
|
console.print(f"[dim]Run 'webber-cli config --init' to create {CONFIG_FILE}[/]")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[info]API Settings[/]")
|
||||||
|
console.print(f" url: {cfg.api.url}")
|
||||||
|
console.print(f" key: {'***' if cfg.api.key else '[dim]not set[/]'}")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[info]CLI Settings[/]")
|
||||||
|
console.print(f" mode: {cfg.cli.mode}")
|
||||||
|
console.print(f" stream: {cfg.cli.stream}")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[info]History[/]")
|
||||||
|
console.print(f" file: {cfg.history.file}")
|
||||||
|
|
||||||
|
|
||||||
|
# Keep 'explore' as an alias for 'chat --mode plan' for backwards compatibility
|
||||||
|
@app.command(hidden=True)
|
||||||
|
def explore(
|
||||||
|
query: str = typer.Argument(..., help="What to search for"),
|
||||||
|
directory: str = typer.Option(
|
||||||
|
".",
|
||||||
|
"--directory",
|
||||||
|
"-d",
|
||||||
|
help="Working directory",
|
||||||
|
),
|
||||||
|
api_url: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--api",
|
||||||
|
"-a",
|
||||||
|
help="Webber API URL (default from config)",
|
||||||
|
),
|
||||||
|
stream: bool = typer.Option(
|
||||||
|
None,
|
||||||
|
"--stream/--no-stream",
|
||||||
|
"-s",
|
||||||
|
help="Stream responses in real-time (default from config)",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
[DEPRECATED] One-shot exploration (use 'chat --mode plan' instead).
|
||||||
|
|
||||||
|
Runs the Task agent in plan (read-only) mode for a single query.
|
||||||
|
"""
|
||||||
|
# Resolve config defaults
|
||||||
|
config = get_config()
|
||||||
|
url = api_url or config.api.url
|
||||||
|
use_stream = stream if stream is not None else config.cli.stream
|
||||||
|
|
||||||
|
console.print("[dim]Note: 'explore' is deprecated. Use 'chat --mode plan' for interactive mode.[/]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
|
if not Path(working_dir).exists():
|
||||||
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
asyncio.run(_explore(url, query, working_dir, use_stream))
|
||||||
|
|
||||||
|
|
||||||
|
async def _explore(
|
||||||
|
api_url: str, query: str, working_dir: str, stream: bool = True
|
||||||
|
) -> None:
|
||||||
|
"""Execute exploration query in plan mode."""
|
||||||
|
theme = get_theme()
|
||||||
|
mode = PermissionMode.plan
|
||||||
|
|
||||||
|
async with WebberClient(api_url) as client:
|
||||||
|
# Check API health
|
||||||
|
if not await client.health_check():
|
||||||
|
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
||||||
|
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
||||||
|
console.print(f"[dim]Query:[/] {query}")
|
||||||
|
console.print(f"[dim]Mode:[/] [success]plan[/] (read-only)")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
if stream:
|
||||||
|
# Stream response with structured events
|
||||||
|
try:
|
||||||
|
async for event in client.run_agent_stream(
|
||||||
|
"task", query, working_dir, mode
|
||||||
|
):
|
||||||
|
if event.event == StreamEventType.thinking:
|
||||||
|
console.print(f"[dim]{event.message or 'Thinking...'}[/]")
|
||||||
|
elif event.event == StreamEventType.tool_start:
|
||||||
|
console.print(f"[info]→ {event.tool}[/]", end="")
|
||||||
|
elif event.event == StreamEventType.tool_done:
|
||||||
|
console.print(f" [success]✓[/] [dim]{event.result_summary or 'done'}[/]")
|
||||||
|
elif event.event in (StreamEventType.response, StreamEventType.chunk):
|
||||||
|
if event.text:
|
||||||
|
sys.stdout.write(event.text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
elif event.event == StreamEventType.error:
|
||||||
|
console.print(f"\n[error]Error:[/] {event.error_message}")
|
||||||
|
console.print() # Newline after streaming
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"\n[error]Stream error:[/] {e}")
|
||||||
|
else:
|
||||||
|
# Batch mode with spinner
|
||||||
|
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
||||||
|
result = await client.run_agent("task", query, working_dir, mode)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
console.print(Panel(
|
||||||
|
Markdown(result.response),
|
||||||
|
title="[success]Findings[/]",
|
||||||
|
border_style=theme.colors.border_success,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
console.print(Panel(
|
||||||
|
f"[error]{result.error}[/]",
|
||||||
|
title="[error]Error[/]",
|
||||||
|
border_style=theme.colors.border_error,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
This directory contains an application to analyse for the webber coding agent to see what it reports this directory is for. It is to test the llm.
|
||||||
Reference in New Issue
Block a user