Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 d385f47395 chore: release api v1.0.0
Build and Push API / release (push) Successful in 4s
Build and Push API / build (push) Successful in 2m26s
- Event-based streaming for task agent
- Retry logic when LLM responds without calling tools
- Hardened prompts to enforce tool use
- Working directory context in all agent prompts
- Project paused: local LLMs not capable enough for agentic use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:54:33 +01:00
jpmschweitzerandClaude Opus 4.5 6ea520519c fix: remove invalid mode kwarg from trace_span + add integration tests
- Remove mode=mode.value from trace_span calls (trace_span only accepts
  name and logger parameters)
- Add TestPermissionModeIntegration tests that verify mode string->enum
  conversion works correctly through the full request flow
- Add TestAgentMethodSignatures tests that verify function signatures
  match expected interfaces (catches invalid kwargs at test time)

These tests would have caught both the mode string/enum issue and the
trace_span invalid kwarg issue before they hit production.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:46:28 +01:00
jpmschweitzerandClaude Opus 4.5 2f11ad79cf fix: handle mode as string due to use_enum_values=True
The BaseSchema has use_enum_values=True which makes Pydantic store
enum values as strings. Added _get_mode() helper to convert back to
PermissionMode enum before passing to agent methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:07:55 +01:00
jpmschweitzerandClaude Opus 4.5 9be877e9a0 feat: make chat the default command
Running 'webber-cli' without arguments now starts chat mode.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:09:39 +01:00
jpmschweitzerandClaude Opus 4.5 b87e61248e feat: add config file support to CLI
- Add ~/.webber/config.toml for persistent settings
- Support api.url, api.key, cli.mode, cli.stream, history.file
- Environment variables override config file values
- Add 'config' command to show settings and init config file
- Update all commands to use config defaults

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:06:13 +01:00
jpmschweitzerandClaude Opus 4.5 daa9543790 feat: add session persistence to CLI
- Add save-only endpoint (POST /conversations/{id}/save) for persisting
  messages without triggering agent execution
- Add sessions command to list previous conversation sessions
- Add --resume flag to chat command for resuming sessions by ID
- Buffer streamed responses and save after completion
- Update AGENTS.md with session commands and remove outdated limitation
- Add 3 new tests for save endpoint (208 total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:50:25 +01:00
jpmschweitzerandClaude Opus 4.5 b7956f88ed feat: add permission modes and CLI orchestration layer
Permission Modes:
- Add default/plan/auto_accept modes controlling tool access
- Plan mode restricts Task agent to read-only tools only
- Auto-accept mode bypasses approval prompts (with confirmation)

Approval Scaffolding:
- Add ApprovalRule/ApprovalRuleSet for granular tool control
- Pattern-based matching on tool name and arguments
- Default rules for common safe/dangerous patterns
- Prep for future bidirectional approval flow

CLI Refactor:
- Default to Task agent (main orchestrator)
- Add --mode flag and runtime mode switching
- Integrate prompt_toolkit for better UX:
  - Persistent command history (~/.webber_history)
  - Tab completion for commands and file paths
  - Auto-suggest from history
- Deprecate standalone 'explore' command

Other:
- Split CHANGELOG.md into per-package files
- Update AGENTS.md release procedure for both packages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:48:07 +01:00
jpmschweitzerandClaude Opus 4.5 acf231eb66 feat: add retry logic for transient failures
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m14s
- @with_retry decorator and retry_async() function
- Exponential backoff with jitter
- Retries on: timeout, connection errors, HTTP 429/5xx
- Web search tool now retries on network failures
- Configurable via RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY, RETRY_MAX_DELAY
- 29 new tests (205 total passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:22:16 +01:00
30 changed files with 3589 additions and 469 deletions
+45 -11
View File
@@ -88,11 +88,37 @@ cd webber-cli
# Check API connection
.venv/bin/webber-cli status
# Explore a directory
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
# Interactive chat mode
# Interactive chat (default mode - full capabilities)
.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.
@@ -153,9 +179,10 @@ pytest tests/ -v
# 1. Load the template
./sandbox.sh load calculator-cli
# 2. Have Webber explore it
# 2. Have Webber explore it (plan mode = read-only)
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
cat ../webber-sandbox/TASKS.md
@@ -192,14 +219,23 @@ Uses prefixed tags:
**NEVER push a tag before updating version files.** Follow this exact order:
```bash
# 1. Update version in pyproject.toml
# 2. Update CHANGELOG.md with release notes
# For API releases:
# 1. Update version in webber-api/pyproject.toml
# 2. Update webber-api/CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release api vX.Y.Z"
# 4. Create the tag (AFTER the commit)
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
git push origin main --tags
```
@@ -249,7 +285,5 @@ cd webber-api
## Known Limitations
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
2. **No conversation memory** - CLI chat mode doesn't persist between sessions
3. **No streaming** - Responses appear all at once
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
+6 -137
View File
@@ -1,141 +1,10 @@
# 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
## [Unreleased]
## [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 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)
Each package is versioned independently using prefixed git tags:
- `api/vX.Y.Z` for API releases
- `cli/vX.Y.Z` for CLI releases
+173
View File
@@ -0,0 +1,173 @@
# 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.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)
+16 -12
View File
@@ -2,9 +2,9 @@
> Tracking progress towards Claude Code-like functionality
## Current Status: ~80% Complete
## Current Status: ~85% Complete
Last updated: 2026-01-11
Last updated: 2026-01-14
---
@@ -122,11 +122,13 @@ Last updated: 2026-01-11
|---------|----------|-------------|------------|
| **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 |
| **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 |
| **Git integration** | CLI | Auto-commit, branch management | Medium |
| **Agent handoff** | Orchestration | ExplorePlan → Task workflow | High |
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low |
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
| ~~**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
@@ -134,7 +136,7 @@ Last updated: 2026-01-11
|---------|----------|-------------|------------|
| **Notebook editing** | Tools | Jupyter cell manipulation | Medium |
| **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 |
| **Parallel agents** | Orchestration | Concurrent agent execution | High |
| **Agent memory** | Orchestration | Shared context between agents | Medium |
@@ -149,13 +151,14 @@ Last updated: 2026-01-11
| API tests | 11 | 11 | ✅ |
| Plan agent tests | 15 | 15 | ✅ |
| Task agent tests | 15 | 15 | ✅ |
| Conversation tests | 19 | 19 | ✅ |
| Conversation tests | 22 | 22 | ✅ |
| Token tests | 6 | 6 | ✅ |
| Retry tests | 29 | 29 | ✅ |
| Security tests | 14 | 14 | ✅ |
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
| E2E tests | 12 | 12 | ✅ Full API workflow |
**Total: 176 tests passing**
**Total: 208 tests passing**
**Test breakdown:**
- Read/Glob/Grep tools: 17 tests
@@ -166,8 +169,9 @@ Last updated: 2026-01-11
- API endpoints: 11 tests
- Plan agent: 15 tests
- Task agent: 15 tests
- Conversations: 19 tests
- Conversations: 22 tests
- Tokens: 6 tests
- Retry: 29 tests
- Security: 14 tests
- Health checks: 2 tests
- Integration (LLM): 10 tests
@@ -222,9 +226,9 @@ cd webber-api && ./wakeup.sh
# CLI commands (from webber-cli/)
.venv/bin/webber-cli status # Check API connection
.venv/bin/webber-cli explore "find tests" # One-shot exploration
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
.venv/bin/webber-cli chat # Interactive mode
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
# API endpoints
curl http://localhost:8095/health
+3
View File
@@ -217,6 +217,9 @@ All settings via environment variables or `.env`:
| 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) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "webber-api"
version = "0.4.1"
version = "1.0.0"
description = "Webber API - Multi-Agent AI Development Server"
authors = [
{name = "jpmschweitzer"}
+212
View File
@@ -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
+20 -4
View File
@@ -79,6 +79,14 @@ class ExploreAgentImpl(BaseAgent):
from src.domains.agents.explore.tools import register_explore_tools
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()
async def run(
self,
@@ -98,16 +106,20 @@ class ExploreAgentImpl(BaseAgent):
Returns:
Agent response with findings
"""
effective_working_dir = working_dir or os.getcwd()
ctx = ExploreContext(
working_dir=working_dir or os.getcwd(),
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("explore_agent_run"):
try:
# 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
except Exception as e:
logger.exception(f"Explore agent error: {e}")
@@ -126,15 +138,19 @@ class ExploreAgentImpl(BaseAgent):
Yields text chunks as they become available.
"""
effective_working_dir = working_dir or os.getcwd()
ctx = ExploreContext(
working_dir=working_dir or os.getcwd(),
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("explore_agent_stream"):
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():
yield chunk
except Exception as e:
+20 -4
View File
@@ -82,6 +82,14 @@ class PlanAgentImpl(BaseAgent):
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,
@@ -101,16 +109,20 @@ class PlanAgentImpl(BaseAgent):
Returns:
Implementation plan with steps and critical files
"""
effective_working_dir = working_dir or os.getcwd()
ctx = PlanContext(
working_dir=working_dir or os.getcwd(),
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(prompt, deps=ctx)
result = await self.agent.run(full_prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Plan agent error: {e}")
@@ -128,15 +140,19 @@ class PlanAgentImpl(BaseAgent):
Yields text chunks as they become available.
"""
effective_working_dir = working_dir or os.getcwd()
ctx = PlanContext(
working_dir=working_dir or os.getcwd(),
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(prompt, deps=ctx) as result:
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:
+49 -14
View File
@@ -1,5 +1,13 @@
"""
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
from fastapi import APIRouter, HTTPException
@@ -16,7 +24,16 @@ from src.domains.agents.schemas import (
AgentRunResponse,
AgentInfo,
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
logger = get_logger(__name__)
@@ -40,6 +57,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
Run an agent with the given prompt.
The agent will use tools to explore the codebase and answer questions.
Permission mode controls which tools are available.
"""
# Get the requested agent
agent = get_agent(request.agent_type)
@@ -49,16 +67,21 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
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:
# Run the agent
# Run the agent with mode
response = await agent.run(
request.prompt,
working_dir=request.working_dir,
mode=mode,
)
return AgentRunResponse(
response=response,
agent_type=request.agent_type,
mode=request.mode, # Keep original for response
success=True,
)
@@ -67,6 +90,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
return AgentRunResponse(
response="",
agent_type=request.agent_type,
mode=request.mode,
success=False,
error=str(e),
)
@@ -78,11 +102,16 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
"""
Run an agent with streaming response.
Returns Server-Sent Events (SSE) with text chunks.
Event types:
- "chunk": Text chunk from the agent
- "done": Stream complete
- "error": Error occurred
Returns Server-Sent Events (SSE) with structured events.
Permission mode controls which tools are available.
Event types (from StreamEvent):
- 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)
if not agent:
@@ -91,22 +120,28 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
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():
try:
async for chunk in agent.run_stream(
async for event in agent.run_stream(
request.prompt,
working_dir=request.working_dir,
mode=mode,
):
# SSE format: data: {json}\n\n
event = {"event": "chunk", "data": chunk}
yield f"data: {json.dumps(event)}\n\n"
# Signal completion
yield f"data: {json.dumps({'event': 'done'})}\n\n"
# Handle both StreamEvent objects and legacy string chunks
if isinstance(event, StreamEvent):
# New structured event format
event_data = event.model_dump(exclude_none=True)
yield f"data: {json.dumps(event_data)}\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:
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"
return StreamingResponse(
+133 -1
View File
@@ -1,22 +1,121 @@
"""
Request and response schemas for agent API.
"""
from enum import Enum
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):
"""Request to run an agent."""
prompt: 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):
"""Response from agent execution."""
response: str
agent_type: str
mode: PermissionMode = PermissionMode.default
success: bool = True
error: str | None = None
# Prep for approval flow - if set, CLI should handle approval
pending_approval: ToolApprovalRequest | None = None
class AgentInfo(BaseSchema):
@@ -28,3 +127,36 @@ class AgentInfo(BaseSchema):
class AgentListResponse(BaseSchema):
"""List of available agents."""
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)
+270 -50
View File
@@ -3,19 +3,22 @@ Task Agent implementation using PydanticAI.
Full orchestrator agent that can:
- Execute multi-step tasks autonomously
- Use all tools (read + write)
- 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
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.task.prompts import TASK_SYSTEM_PROMPT
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
@@ -29,20 +32,43 @@ class TaskContext(AgentContext):
Context for task agent tools.
Passed to all tool functions via RunContext.
Uses the same fields as base AgentContext.
Extends base AgentContext with permission mode and event queue.
"""
pass
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.
Has access to ALL tools:
- Read-only: read_file, glob_files, grep_content, bash_readonly
- Write: edit_file, write_file, bash
- External: web_search
- Orchestration: spawn_agent (launch sub-agents)
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.
@@ -53,10 +79,22 @@ class TaskAgentImpl(BaseAgent):
def __init__(self):
"""Initialize the task agent."""
self._agent: Agent[TaskContext, str] | None = None
# Cache agents by mode to avoid recreating
self._agents: dict[PermissionMode, Agent[TaskContext, str]] = {}
self._settings = get_settings()
def _create_agent(self) -> Agent[TaskContext, str]:
@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(
@@ -64,9 +102,12 @@ class TaskAgentImpl(BaseAgent):
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=TASK_SYSTEM_PROMPT,
system_prompt=system_prompt,
deps_type=TaskContext,
output_type=str,
# Mistral Nemo settings:
@@ -78,15 +119,48 @@ class TaskAgentImpl(BaseAgent):
},
)
# Register all tools including orchestration
self._register_tools(agent)
# Register tools based on mode
self._register_tools(agent, mode)
return agent
def _register_tools(self, agent: Agent[TaskContext, str]) -> None:
"""Register all tools with the agent."""
from src.domains.agents.task.tools import register_task_tools
register_task_tools(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(
@@ -94,6 +168,7 @@ class TaskAgentImpl(BaseAgent):
prompt: str,
working_dir: str | None = None,
allowed_paths: list[str] | None = None,
mode: PermissionMode = PermissionMode.default,
**kwargs: Any
) -> str:
"""
@@ -103,51 +178,196 @@ class TaskAgentImpl(BaseAgent):
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
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
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"):
try:
# Use run() not run_stream() - Ollama has bugs with streaming + tools
result = await self.agent.run(prompt, deps=ctx)
return result.output
except Exception as e:
logger.exception(f"Task agent error: {e}")
raise
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[str]:
) -> AsyncIterator[StreamEvent]:
"""
Run the task agent with streaming output.
Run the task agent with structured event streaming.
Yields text chunks as they become available.
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
"""
ctx = TaskContext(
working_dir=working_dir or os.getcwd(),
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
timeout_seconds=self._settings.tool_timeout_seconds,
)
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"):
try:
async with self.agent.run_stream(prompt, deps=ctx) as result:
async for chunk in result.stream_text():
yield chunk
except Exception as e:
logger.exception(f"Task agent stream error: {e}")
raise
# 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
@@ -168,7 +388,7 @@ async def task_stream(
prompt: str,
working_dir: str | None = None,
**kwargs: Any
) -> AsyncIterator[str]:
"""Run task execution with streaming."""
async for chunk in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
yield chunk
) -> AsyncIterator[StreamEvent]:
"""Run task execution with event streaming."""
async for event in task_agent.run_stream(prompt, working_dir=working_dir, **kwargs):
yield event
+84 -10
View File
@@ -3,12 +3,81 @@ 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)
- 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.
@@ -31,16 +100,21 @@ External:
Orchestration:
- spawn_agent: Launch sub-agents for focused tasks
WORKFLOW:
1. Understand the task requirements
2. Break down into sub-tasks if complex
3. Use spawn_agent for research (explore) or planning (plan)
4. Execute implementation steps using write tools
5. Validate changes (run tests if applicable)
6. Return consolidated summary
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"
@@ -66,8 +140,8 @@ GIT DISCIPLINE:
- Run tests before committing
RULES:
- ALWAYS use tools first, then analyze results
- Never guess file contents - read them first
- 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
+103 -71
View File
@@ -1,11 +1,9 @@
"""
Tool registrations for the Task agent.
The Task agent has access to ALL tools:
- Read-only tools (same as Explore/Plan)
- Write tools (edit, write, bash full)
- External tools (web search)
- Orchestration (spawn sub-agents)
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
@@ -20,19 +18,8 @@ from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool
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
"""
# === Read-only tools ===
def _register_read_file(agent: Agent[AgentContext, str]) -> None:
"""Register read_file tool."""
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
@@ -60,6 +47,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
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],
@@ -91,6 +81,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
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],
@@ -124,6 +117,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
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],
@@ -159,6 +155,94 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
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
@@ -295,56 +379,4 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
return result.to_string()
# === Orchestration tools ===
@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}"
_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)
@@ -15,6 +15,8 @@ from src.domains.conversations.schemas import (
ConversationResponse,
CreateConversationRequest,
MessageResponse,
SaveMessagesRequest,
SaveMessagesResponse,
)
from src.domains.conversations.service import ConversationService
from src.shared.auth import require_auth
@@ -187,3 +189,51 @@ async def add_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,
)
@@ -21,6 +21,15 @@ class AddMessageRequest(BaseModel):
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):
@@ -77,3 +86,10 @@ class AddMessageResponse(BaseModel):
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
+27 -9
View File
@@ -9,6 +9,7 @@ import httpx
from src.domains.tools.base import BaseTool, ToolResult
from src.shared.config import get_settings
from src.shared.logging import logged, get_logger
from src.shared.retry import retry_async
logger = get_logger(__name__)
@@ -73,6 +74,24 @@ IMPORTANT:
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
self.timeout = timeout or settings.searxng_timeout
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()
async def execute(
@@ -111,16 +130,15 @@ IMPORTANT:
params["categories"] = categories
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.searxng_url}/search",
params=params,
)
response.raise_for_status()
data = response.json()
data = await retry_async(
self._fetch_search_results,
params,
max_attempts=self.retry_max_attempts,
base_delay=self.retry_base_delay,
max_delay=self.retry_max_delay,
)
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:
return self._error(f"Search failed: HTTP {e.response.status_code}")
except httpx.RequestError as e:
+5
View File
@@ -90,6 +90,11 @@ class Settings(BaseSettings):
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(
env_file=".env",
case_sensitive=False,
+215
View File
@@ -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
+185
View File
@@ -1,7 +1,13 @@
"""
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
from unittest.mock import AsyncMock, patch, MagicMock
from src.domains.agents.schemas import PermissionMode
class TestAgentListEndpoint:
@@ -160,3 +166,182 @@ class TestAgentStreamEndpoint:
)
# Unknown agent returns 400, not streaming
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"]
+84
View File
@@ -297,3 +297,87 @@ class TestSummarization:
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
+244
View File
@@ -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
+5 -5
View File
@@ -206,14 +206,14 @@ class TestTaskAgentProperties:
# Create a fresh instance
fresh_agent = TaskAgentImpl()
# _agent should be None before first access
assert fresh_agent._agent is None
# _agents dict should be empty before first access
assert len(fresh_agent._agents) == 0
# Access the agent property
# Access the agent property (creates default mode agent)
_ = fresh_agent.agent
# Now _agent should be set
assert fresh_agent._agent is not None
# Now _agents should have one entry
assert len(fresh_agent._agents) == 1
class TestAllAgentsRegistered:
+21
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@
httpx~=0.28.1
typer~=0.15.0
rich~=13.9.0
prompt_toolkit~=3.0.48
+315 -11
View File
@@ -2,20 +2,72 @@
Webber API client.
Communicates with the Webber API backend for agent execution.
Supports permission modes for controlling agent tool access.
"""
import json
import httpx
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
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
class AgentResponse:
"""Response from agent execution."""
response: str
agent_type: str
success: bool
mode: PermissionMode = PermissionMode.default
error: str | None = None
@@ -26,6 +78,47 @@ class AgentInfo:
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:
"""
Client for the Webber API.
@@ -104,14 +197,16 @@ class WebberClient:
agent_type: str,
prompt: str,
working_dir: str = ".",
mode: PermissionMode = PermissionMode.default,
) -> AgentResponse:
"""
Run an agent with the given prompt.
Args:
agent_type: Type of agent (e.g., "explore")
agent_type: Type of agent (e.g., "task")
prompt: User prompt/query
working_dir: Working directory for the agent
mode: Permission mode controlling tool access
Returns:
AgentResponse with the result
@@ -123,6 +218,7 @@ class WebberClient:
"agent_type": agent_type,
"prompt": prompt,
"working_dir": working_dir,
"mode": mode.value,
},
)
response.raise_for_status()
@@ -131,6 +227,7 @@ class WebberClient:
response=data.get("response", ""),
agent_type=data.get("agent_type", agent_type),
success=data.get("success", True),
mode=PermissionMode(data.get("mode", "default")),
error=data.get("error"),
)
@@ -139,17 +236,19 @@ class WebberClient:
agent_type: str,
prompt: str,
working_dir: str = ".",
) -> AsyncIterator[str]:
mode: PermissionMode = PermissionMode.default,
) -> AsyncIterator[StreamEvent]:
"""
Run an agent with streaming response.
Args:
agent_type: Type of agent (e.g., "explore")
agent_type: Type of agent (e.g., "task")
prompt: User prompt/query
working_dir: Working directory for the agent
mode: Permission mode controlling tool access
Yields:
Text chunks as they arrive
StreamEvent objects as they arrive
"""
# Use a fresh client for streaming with longer timeout
async with httpx.AsyncClient(
@@ -163,6 +262,7 @@ class WebberClient:
"agent_type": agent_type,
"prompt": prompt,
"working_dir": working_dir,
"mode": mode.value,
},
) as response:
response.raise_for_status()
@@ -170,16 +270,220 @@ class WebberClient:
if line.startswith("data: "):
try:
data = json.loads(line[6:])
event = data.get("event")
if event == "chunk":
yield data.get("data", "")
elif event == "error":
raise Exception(data.get("data", "Unknown error"))
elif event == "done":
event_type = data.get("event")
# Parse event type
try:
evt_type = StreamEventType(event_type)
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
except json.JSONDecodeError:
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 context manager entry."""
return self
+152
View File
@@ -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
View File
@@ -5,45 +5,146 @@ Webber CLI - Client for the Webber API.
Usage:
webber-cli --help
webber-cli chat [OPTIONS]
webber-cli explore QUERY [OPTIONS]
webber-cli status
"""
import asyncio
import os
import sys
from pathlib import Path
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.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
# === 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(
name="webber-cli",
help="CLI client for the Webber API",
no_args_is_help=True,
no_args_is_help=False,
invoke_without_command=True,
add_completion=False,
)
console = get_console()
# Default API URL (can be overridden via env or option)
# Development port is 8095, production is 8086
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
def _get_api_url() -> str:
"""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:
"""Display version and exit."""
if value:
from cli import __version__
from webber_cli import __version__
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
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(
ctx: typer.Context,
version: bool = typer.Option(
False,
"--version",
@@ -54,7 +155,79 @@ def main(
),
) -> None:
"""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()
@@ -63,50 +236,95 @@ def chat(
".",
"--directory",
"-d",
help="Working directory for exploration",
help="Working directory for the agent",
),
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
agent: str = typer.Option(
"explore",
"--agent",
help="Agent to use",
mode: str = typer.Option(
None,
"--mode",
"-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(
True,
None,
"--stream/--no-stream",
"-s",
help="Stream responses in real-time",
help="Stream responses in real-time (default from config)",
),
) -> 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())
if not Path(working_dir).exists():
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
raise typer.Exit(1)
# Parse and validate mode
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:
console.print("\n[dim]Goodbye![/]")
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:
"""Interactive chat loop."""
"""Interactive chat loop with the Task agent."""
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
if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
@@ -116,28 +334,99 @@ async def _chat_loop(
# Get agent info
agent_info = await client.get_agent(agent_type)
if not agent_info:
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
agents = await client.list_agents()
console.print("[dim]Available agents:[/]")
for a in agents:
console.print(f" - {a.name}: {a.description}")
console.print(f"[error]Error:[/] Task agent not found")
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
console.print()
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
mode = "streaming" if stream else "batch"
console.print(f"[dim]Mode:[/] {mode}")
console.print(f"[dim]Mode:[/] {mode_display[mode]}")
console.print(f"[dim]Streaming:[/] {'enabled' if stream else 'disabled'}")
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()
current_mode = mode
# Create prompt session with history and completion
session = create_prompt_session(working_dir)
# Chat loop
while True:
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:
continue
@@ -152,36 +441,123 @@ async def _chat_loop(
if user_input.lower().startswith("cd "):
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():
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}[/]")
else:
console.print(f"[error]Directory not found:[/] {new_dir}")
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()
if stream:
# Stream response in real-time
full_response = ""
# Stream response with structured events
response_chunks: list[str] = []
current_tool: str | None = None
try:
async for chunk in client.run_agent_stream(
agent_type, user_input, working_dir
async for event in client.run_agent_stream(
agent_type, user_input, working_dir, current_mode
):
sys.stdout.write(chunk)
sys.stdout.flush()
full_response += chunk
if event.event == StreamEventType.thinking:
# Show thinking status
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
# 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:
console.print(f"\n[error]Stream error:[/] {e}")
else:
# Batch mode with 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:
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:
console.print(f"[error]Error:[/] {result.error}")
@@ -194,98 +570,18 @@ async def _chat_loop(
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()
def status(
api_url: str = typer.Option(
DEFAULT_API_URL,
None,
"--api",
"-a",
help="Webber API URL",
help="Webber API URL (default from config)",
),
) -> None:
"""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:
@@ -304,5 +600,157 @@ async def _status(api_url: str) -> None:
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__":
app()
+1
View File
@@ -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.