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>
This commit is contained in:
@@ -192,14 +192,23 @@ Uses prefixed tags:
|
|||||||
**NEVER push a tag before updating version files.** Follow this exact order:
|
**NEVER push a tag before updating version files.** Follow this exact order:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Update version in pyproject.toml
|
# For API releases:
|
||||||
# 2. Update CHANGELOG.md with release notes
|
# 1. Update version in webber-api/pyproject.toml
|
||||||
|
# 2. Update webber-api/CHANGELOG.md with release notes
|
||||||
# 3. Commit the version bump
|
# 3. Commit the version bump
|
||||||
git add -A && git commit -m "chore: release api vX.Y.Z"
|
git add -A && git commit -m "chore: release api vX.Y.Z"
|
||||||
|
|
||||||
# 4. Create the tag (AFTER the commit)
|
# 4. Create the tag (AFTER the commit)
|
||||||
git tag api/vX.Y.Z
|
git tag api/vX.Y.Z
|
||||||
|
# 5. Push everything together
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
# For CLI releases:
|
||||||
|
# 1. Update version in webber-cli/pyproject.toml
|
||||||
|
# 2. Update webber-cli/CHANGELOG.md with release notes
|
||||||
|
# 3. Commit the version bump
|
||||||
|
git add -A && git commit -m "chore: release cli vX.Y.Z"
|
||||||
|
# 4. Create the tag (AFTER the commit)
|
||||||
|
git tag cli/vX.Y.Z
|
||||||
# 5. Push everything together
|
# 5. Push everything together
|
||||||
git push origin main --tags
|
git push origin main --tags
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-147
@@ -1,151 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
This monorepo maintains separate changelogs for each package:
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
- **[webber-api/CHANGELOG.md](webber-api/CHANGELOG.md)** - API server changes
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
- **[webber-cli/CHANGELOG.md](webber-cli/CHANGELOG.md)** - CLI client changes
|
||||||
|
|
||||||
## [Unreleased]
|
Each package is versioned independently using prefixed git tags:
|
||||||
|
- `api/vX.Y.Z` for API releases
|
||||||
## [0.4.2] - 2026-01-11
|
- `cli/vX.Y.Z` for CLI releases
|
||||||
|
|
||||||
### 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 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)
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 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]
|
||||||
|
|
||||||
|
## [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)
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
REST API routes for agents.
|
REST API routes for agents.
|
||||||
|
|
||||||
|
Supports permission modes for controlling agent tool access:
|
||||||
|
- default: All tools available (approval may be required)
|
||||||
|
- plan: Read-only tools only
|
||||||
|
- auto_accept: All tools, no approval prompts
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -16,6 +21,7 @@ from src.domains.agents.schemas import (
|
|||||||
AgentRunResponse,
|
AgentRunResponse,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
PermissionMode,
|
||||||
)
|
)
|
||||||
from src.shared.logging import logged, get_logger
|
from src.shared.logging import logged, get_logger
|
||||||
|
|
||||||
@@ -40,6 +46,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
The agent will use tools to explore the codebase and answer questions.
|
The agent will use tools to explore the codebase and answer questions.
|
||||||
|
Permission mode controls which tools are available.
|
||||||
"""
|
"""
|
||||||
# Get the requested agent
|
# Get the requested agent
|
||||||
agent = get_agent(request.agent_type)
|
agent = get_agent(request.agent_type)
|
||||||
@@ -50,15 +57,17 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the agent
|
# Run the agent with mode
|
||||||
response = await agent.run(
|
response = await agent.run(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=request.mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response=response,
|
response=response,
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode,
|
||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +76,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
|||||||
return AgentRunResponse(
|
return AgentRunResponse(
|
||||||
response="",
|
response="",
|
||||||
agent_type=request.agent_type,
|
agent_type=request.agent_type,
|
||||||
|
mode=request.mode,
|
||||||
success=False,
|
success=False,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
@@ -79,6 +89,8 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Returns Server-Sent Events (SSE) with text chunks.
|
Returns Server-Sent Events (SSE) with text chunks.
|
||||||
|
Permission mode controls which tools are available.
|
||||||
|
|
||||||
Event types:
|
Event types:
|
||||||
- "chunk": Text chunk from the agent
|
- "chunk": Text chunk from the agent
|
||||||
- "done": Stream complete
|
- "done": Stream complete
|
||||||
@@ -96,13 +108,14 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
|||||||
async for chunk in agent.run_stream(
|
async for chunk in agent.run_stream(
|
||||||
request.prompt,
|
request.prompt,
|
||||||
working_dir=request.working_dir,
|
working_dir=request.working_dir,
|
||||||
|
mode=request.mode,
|
||||||
):
|
):
|
||||||
# SSE format: data: {json}\n\n
|
# SSE format: data: {json}\n\n
|
||||||
event = {"event": "chunk", "data": chunk}
|
event = {"event": "chunk", "data": chunk}
|
||||||
yield f"data: {json.dumps(event)}\n\n"
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
# Signal completion
|
# Signal completion
|
||||||
yield f"data: {json.dumps({'event': 'done'})}\n\n"
|
yield f"data: {json.dumps({'event': 'done', 'mode': request.mode.value})}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Stream error: {e}")
|
logger.exception(f"Stream error: {e}")
|
||||||
|
|||||||
@@ -1,22 +1,121 @@
|
|||||||
"""
|
"""
|
||||||
Request and response schemas for agent API.
|
Request and response schemas for agent API.
|
||||||
"""
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes that control agent tool access.
|
||||||
|
|
||||||
|
Aligns with Claude Code's permission model:
|
||||||
|
- default: Full tools, approval required for writes (future)
|
||||||
|
- plan: Read-only tools only, no approval needed
|
||||||
|
- auto_accept: Full tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
auto_accept = "auto_accept"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStatus(str, Enum):
|
||||||
|
"""Status of a tool approval request."""
|
||||||
|
pending = "pending"
|
||||||
|
approved = "approved"
|
||||||
|
denied = "denied"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalAction(str, Enum):
|
||||||
|
"""Action to take when a rule matches."""
|
||||||
|
allow = "allow" # Auto-approve without prompting
|
||||||
|
deny = "deny" # Auto-deny without prompting
|
||||||
|
ask = "ask" # Prompt user for approval
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRule(BaseSchema):
|
||||||
|
"""
|
||||||
|
Granular approval rule for tool execution.
|
||||||
|
|
||||||
|
Allows fine-grained control over which tool calls are allowed:
|
||||||
|
- Pattern matching on tool arguments
|
||||||
|
- Different actions per rule (allow, deny, ask)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Allow curl to localhost
|
||||||
|
ApprovalRule(tool="bash", pattern="curl.*localhost.*", action="allow")
|
||||||
|
|
||||||
|
# Deny any rm command
|
||||||
|
ApprovalRule(tool="bash", pattern="rm\\s+.*", action="deny")
|
||||||
|
|
||||||
|
# Ask for git push
|
||||||
|
ApprovalRule(tool="bash", pattern="git\\s+push.*", action="ask")
|
||||||
|
|
||||||
|
# Allow all file reads in src/
|
||||||
|
ApprovalRule(tool="read_file", pattern=".*/src/.*", action="allow")
|
||||||
|
"""
|
||||||
|
tool: str # Tool name to match (e.g., "bash", "edit_file")
|
||||||
|
pattern: str # Regex pattern to match against tool args
|
||||||
|
action: ApprovalAction # What to do when matched
|
||||||
|
description: str | None = None # Human-readable description of rule
|
||||||
|
priority: int = 0 # Higher priority rules evaluated first
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRuleSet(BaseSchema):
|
||||||
|
"""
|
||||||
|
Collection of approval rules with evaluation logic.
|
||||||
|
|
||||||
|
Rules are evaluated in priority order (highest first).
|
||||||
|
First matching rule determines the action.
|
||||||
|
If no rules match, falls back to default action.
|
||||||
|
"""
|
||||||
|
rules: list[ApprovalRule] = []
|
||||||
|
default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalRequest(BaseSchema):
|
||||||
|
"""
|
||||||
|
Request for tool execution approval.
|
||||||
|
|
||||||
|
Sent from API to CLI when a tool needs user approval.
|
||||||
|
Prep for future bidirectional approval flow.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
tool_name: str
|
||||||
|
tool_args: dict
|
||||||
|
description: str
|
||||||
|
risk_level: str = "write" # "read", "write", "dangerous"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolApprovalResponse(BaseSchema):
|
||||||
|
"""
|
||||||
|
Response to a tool approval request.
|
||||||
|
|
||||||
|
Sent from CLI to API with user's decision.
|
||||||
|
"""
|
||||||
|
request_id: str
|
||||||
|
status: ApprovalStatus
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentRunRequest(BaseSchema):
|
class AgentRunRequest(BaseSchema):
|
||||||
"""Request to run an agent."""
|
"""Request to run an agent."""
|
||||||
prompt: str
|
prompt: str
|
||||||
working_dir: str = "."
|
working_dir: str = "."
|
||||||
agent_type: str = "explore"
|
agent_type: str = "task" # Default to task agent (main agent)
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
|
|
||||||
|
|
||||||
class AgentRunResponse(BaseSchema):
|
class AgentRunResponse(BaseSchema):
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
success: bool = True
|
success: bool = True
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
# Prep for approval flow - if set, CLI should handle approval
|
||||||
|
pending_approval: ToolApprovalRequest | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentInfo(BaseSchema):
|
class AgentInfo(BaseSchema):
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ Task Agent implementation using PydanticAI.
|
|||||||
|
|
||||||
Full orchestrator agent that can:
|
Full orchestrator agent that can:
|
||||||
- Execute multi-step tasks autonomously
|
- 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
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
from pydantic_ai.models.openai import OpenAIModel
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
|
||||||
from src.domains.agents.base import BaseAgent, AgentContext, register_agent
|
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
|
||||||
|
from src.domains.agents.task.prompts import TASK_SYSTEM_PROMPT, TASK_PLAN_MODE_PROMPT
|
||||||
from src.ollama.provider import get_ollama_provider
|
from src.ollama.provider import get_ollama_provider
|
||||||
from src.shared.config import get_settings
|
from src.shared.config import get_settings
|
||||||
from src.shared.logging import logged, get_logger, trace_span
|
from src.shared.logging import logged, get_logger, trace_span
|
||||||
@@ -29,20 +30,21 @@ class TaskContext(AgentContext):
|
|||||||
Context for task agent tools.
|
Context for task agent tools.
|
||||||
|
|
||||||
Passed to all tool functions via RunContext.
|
Passed to all tool functions via RunContext.
|
||||||
Uses the same fields as base AgentContext.
|
Extends base AgentContext with permission mode.
|
||||||
"""
|
"""
|
||||||
pass
|
mode: PermissionMode = PermissionMode.default
|
||||||
|
# Prep for approval flow - tools can check this
|
||||||
|
pending_approvals: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TaskAgentImpl(BaseAgent):
|
class TaskAgentImpl(BaseAgent):
|
||||||
"""
|
"""
|
||||||
Full orchestrator agent for autonomous task execution.
|
Full orchestrator agent for autonomous task execution.
|
||||||
|
|
||||||
Has access to ALL tools:
|
Tool access depends on permission mode:
|
||||||
- Read-only: read_file, glob_files, grep_content, bash_readonly
|
- plan: Read-only tools only (safe exploration)
|
||||||
- Write: edit_file, write_file, bash
|
- default: All tools (approval required for writes - future)
|
||||||
- External: web_search
|
- auto_accept: All tools (no approval prompts)
|
||||||
- Orchestration: spawn_agent (launch sub-agents)
|
|
||||||
|
|
||||||
Can spawn Explore and Plan agents to offload focused tasks,
|
Can spawn Explore and Plan agents to offload focused tasks,
|
||||||
keeping context efficient across complex multi-step work.
|
keeping context efficient across complex multi-step work.
|
||||||
@@ -53,10 +55,22 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the task agent."""
|
"""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()
|
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."""
|
"""Create the PydanticAI agent with Ollama backend."""
|
||||||
# Use sanitized Ollama provider to fix content: null issues
|
# Use sanitized Ollama provider to fix content: null issues
|
||||||
model = OpenAIModel(
|
model = OpenAIModel(
|
||||||
@@ -64,9 +78,12 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
provider=get_ollama_provider(),
|
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(
|
agent: Agent[TaskContext, str] = Agent(
|
||||||
model=model,
|
model=model,
|
||||||
system_prompt=TASK_SYSTEM_PROMPT,
|
system_prompt=system_prompt,
|
||||||
deps_type=TaskContext,
|
deps_type=TaskContext,
|
||||||
output_type=str,
|
output_type=str,
|
||||||
# Mistral Nemo settings:
|
# Mistral Nemo settings:
|
||||||
@@ -78,15 +95,21 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register all tools including orchestration
|
# Register tools based on mode
|
||||||
self._register_tools(agent)
|
self._register_tools(agent, mode)
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
def _register_tools(self, agent: Agent[TaskContext, str]) -> None:
|
def _register_tools(self, agent: Agent[TaskContext, str], mode: PermissionMode) -> None:
|
||||||
"""Register all tools with the agent."""
|
"""Register tools with the agent based on permission mode."""
|
||||||
from src.domains.agents.task.tools import register_task_tools
|
from src.domains.agents.task.tools import register_task_tools, register_readonly_tools
|
||||||
register_task_tools(agent)
|
|
||||||
|
if mode == PermissionMode.plan:
|
||||||
|
# Plan mode: read-only tools only
|
||||||
|
register_readonly_tools(agent)
|
||||||
|
else:
|
||||||
|
# Default and auto_accept: all tools
|
||||||
|
register_task_tools(agent)
|
||||||
|
|
||||||
@logged()
|
@logged()
|
||||||
async def run(
|
async def run(
|
||||||
@@ -94,6 +117,7 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str | None = None,
|
working_dir: str | None = None,
|
||||||
allowed_paths: list[str] | None = None,
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -103,6 +127,7 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
prompt: Description of the task to execute
|
prompt: Description of the task to execute
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
allowed_paths: Restrict tool access to these paths
|
allowed_paths: Restrict tool access to these paths
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Consolidated task summary with results
|
Consolidated task summary with results
|
||||||
@@ -111,12 +136,15 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=working_dir or os.getcwd(),
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with trace_span("task_agent_run"):
|
# Get agent configured for this mode
|
||||||
|
agent = self._get_agent_for_mode(mode)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_run", mode=mode.value):
|
||||||
try:
|
try:
|
||||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
result = await agent.run(prompt, deps=ctx)
|
||||||
result = await self.agent.run(prompt, deps=ctx)
|
|
||||||
return result.output
|
return result.output
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Task agent error: {e}")
|
logger.exception(f"Task agent error: {e}")
|
||||||
@@ -127,22 +155,34 @@ class TaskAgentImpl(BaseAgent):
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str | None = None,
|
working_dir: str | None = None,
|
||||||
allowed_paths: list[str] | None = None,
|
allowed_paths: list[str] | None = None,
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""
|
"""
|
||||||
Run the task agent with streaming output.
|
Run the task agent with streaming output.
|
||||||
|
|
||||||
Yields text chunks as they become available.
|
Args:
|
||||||
|
prompt: Task description
|
||||||
|
working_dir: Working directory
|
||||||
|
allowed_paths: Restrict tool access
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Text chunks as they become available.
|
||||||
"""
|
"""
|
||||||
ctx = TaskContext(
|
ctx = TaskContext(
|
||||||
working_dir=working_dir or os.getcwd(),
|
working_dir=working_dir or os.getcwd(),
|
||||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||||
timeout_seconds=self._settings.tool_timeout_seconds,
|
timeout_seconds=self._settings.tool_timeout_seconds,
|
||||||
|
mode=mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with trace_span("task_agent_stream"):
|
# Get agent configured for this mode
|
||||||
|
agent = self._get_agent_for_mode(mode)
|
||||||
|
|
||||||
|
async with trace_span("task_agent_stream", mode=mode.value):
|
||||||
try:
|
try:
|
||||||
async with self.agent.run_stream(prompt, deps=ctx) as result:
|
async with agent.run_stream(prompt, deps=ctx) as result:
|
||||||
async for chunk in result.stream_text():
|
async for chunk in result.stream_text():
|
||||||
yield chunk
|
yield chunk
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,10 +3,68 @@ System prompts for the Task agent.
|
|||||||
|
|
||||||
The Task agent is a full orchestrator that can:
|
The Task agent is a full orchestrator that can:
|
||||||
- Execute multi-step tasks autonomously
|
- 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
|
- Spawn sub-agents (Explore, Plan) for focused work
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
TASK_PLAN_MODE_PROMPT = """You are a codebase analysis and planning agent in READ-ONLY mode.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
1. Understand the request
|
||||||
|
2. Explore the codebase to gather context
|
||||||
|
3. Analyze code structure and patterns
|
||||||
|
4. Create detailed implementation plans
|
||||||
|
5. Return findings with actionable recommendations
|
||||||
|
|
||||||
|
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 get deeper analysis:
|
||||||
|
Call spawn_agent with agent_type="explore" and prompt="find authentication code"
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- ALWAYS use tools first, then analyze results
|
||||||
|
- Never guess file contents - read them first
|
||||||
|
- Be thorough in exploration
|
||||||
|
- Provide specific file paths and line numbers in findings
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Structure your response with:
|
||||||
|
|
||||||
|
### Analysis
|
||||||
|
- What was found
|
||||||
|
- Key patterns identified
|
||||||
|
- Relevant files
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
- Suggested approach
|
||||||
|
- Potential concerns
|
||||||
|
- Next steps (to be executed in full mode)
|
||||||
|
"""
|
||||||
|
|
||||||
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
TASK_SYSTEM_PROMPT = """You are an autonomous task execution agent.
|
||||||
|
|
||||||
You have access to ALL tools including file editing, writing, and bash execution.
|
You have access to ALL tools including file editing, writing, and bash execution.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Tool registrations for the Task agent.
|
Tool registrations for the Task agent.
|
||||||
|
|
||||||
The Task agent has access to ALL tools:
|
The Task agent has access to tools based on permission mode:
|
||||||
- Read-only tools (same as Explore/Plan)
|
- Plan mode: Read-only tools only
|
||||||
- Write tools (edit, write, bash full)
|
- Default/auto_accept: All tools including write operations
|
||||||
- External tools (web search)
|
|
||||||
- Orchestration (spawn sub-agents)
|
|
||||||
"""
|
"""
|
||||||
from pydantic_ai import Agent, RunContext
|
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
|
from src.domains.tools.shell.bash_full import BashTool
|
||||||
|
|
||||||
|
|
||||||
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
def _register_read_file(agent: Agent[AgentContext, str]) -> None:
|
||||||
"""
|
"""Register read_file tool."""
|
||||||
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 ===
|
|
||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def read_file(
|
async def read_file(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[AgentContext],
|
||||||
@@ -60,6 +47,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
)
|
)
|
||||||
return result.to_string()
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register glob_files tool."""
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def glob_files(
|
async def glob_files(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[AgentContext],
|
||||||
@@ -91,6 +81,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
)
|
)
|
||||||
return result.to_string()
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_grep_content(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register grep_content tool."""
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def grep_content(
|
async def grep_content(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[AgentContext],
|
||||||
@@ -124,6 +117,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
)
|
)
|
||||||
return result.to_string()
|
return result.to_string()
|
||||||
|
|
||||||
|
|
||||||
|
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
|
||||||
|
"""Register bash_readonly tool."""
|
||||||
@agent.tool
|
@agent.tool
|
||||||
async def bash_readonly(
|
async def bash_readonly(
|
||||||
ctx: RunContext[AgentContext],
|
ctx: RunContext[AgentContext],
|
||||||
@@ -159,6 +155,94 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
)
|
)
|
||||||
return result.to_string()
|
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 ===
|
# === Write tools ===
|
||||||
|
|
||||||
@agent.tool
|
@agent.tool
|
||||||
@@ -295,56 +379,4 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
|
|||||||
return result.to_string()
|
return result.to_string()
|
||||||
|
|
||||||
# === Orchestration tools ===
|
# === Orchestration tools ===
|
||||||
|
_register_spawn_agent(agent, readonly_only=False)
|
||||||
@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}"
|
|
||||||
|
|||||||
@@ -206,14 +206,14 @@ class TestTaskAgentProperties:
|
|||||||
# Create a fresh instance
|
# Create a fresh instance
|
||||||
fresh_agent = TaskAgentImpl()
|
fresh_agent = TaskAgentImpl()
|
||||||
|
|
||||||
# _agent should be None before first access
|
# _agents dict should be empty before first access
|
||||||
assert fresh_agent._agent is None
|
assert len(fresh_agent._agents) == 0
|
||||||
|
|
||||||
# Access the agent property
|
# Access the agent property (creates default mode agent)
|
||||||
_ = fresh_agent.agent
|
_ = fresh_agent.agent
|
||||||
|
|
||||||
# Now _agent should be set
|
# Now _agents should have one entry
|
||||||
assert fresh_agent._agent is not None
|
assert len(fresh_agent._agents) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestAllAgentsRegistered:
|
class TestAllAgentsRegistered:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Changelog - Webber CLI
|
||||||
|
|
||||||
|
All notable changes to the Webber CLI will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-01-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial CLI release as part of monorepo reorganization
|
||||||
|
- Typer + Rich foundation with console theming
|
||||||
|
- Commands:
|
||||||
|
- `webber-cli status` - Check API connection
|
||||||
|
- `webber-cli explore` - One-shot codebase exploration
|
||||||
|
- `webber-cli chat` - Interactive conversation mode
|
||||||
|
- Streaming support with `--stream` flag (default: enabled)
|
||||||
|
- Markdown rendering for agent responses
|
||||||
|
- Configurable API URL via `WEBBER_API_URL` environment variable
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
httpx~=0.28.1
|
httpx~=0.28.1
|
||||||
typer~=0.15.0
|
typer~=0.15.0
|
||||||
rich~=13.9.0
|
rich~=13.9.0
|
||||||
|
prompt_toolkit~=3.0.48
|
||||||
|
|||||||
@@ -2,20 +2,36 @@
|
|||||||
Webber API client.
|
Webber API client.
|
||||||
|
|
||||||
Communicates with the Webber API backend for agent execution.
|
Communicates with the Webber API backend for agent execution.
|
||||||
|
Supports permission modes for controlling agent tool access.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMode(str, Enum):
|
||||||
|
"""
|
||||||
|
Permission modes controlling agent tool access.
|
||||||
|
|
||||||
|
- default: All tools available (approval may be required)
|
||||||
|
- plan: Read-only tools only
|
||||||
|
- auto_accept: All tools, no approval prompts
|
||||||
|
"""
|
||||||
|
default = "default"
|
||||||
|
plan = "plan"
|
||||||
|
auto_accept = "auto_accept"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AgentResponse:
|
class AgentResponse:
|
||||||
"""Response from agent execution."""
|
"""Response from agent execution."""
|
||||||
response: str
|
response: str
|
||||||
agent_type: str
|
agent_type: str
|
||||||
success: bool
|
success: bool
|
||||||
|
mode: PermissionMode = PermissionMode.default
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -104,14 +120,16 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
"""
|
"""
|
||||||
Run an agent with the given prompt.
|
Run an agent with the given prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AgentResponse with the result
|
AgentResponse with the result
|
||||||
@@ -123,6 +141,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -131,6 +150,7 @@ class WebberClient:
|
|||||||
response=data.get("response", ""),
|
response=data.get("response", ""),
|
||||||
agent_type=data.get("agent_type", agent_type),
|
agent_type=data.get("agent_type", agent_type),
|
||||||
success=data.get("success", True),
|
success=data.get("success", True),
|
||||||
|
mode=PermissionMode(data.get("mode", "default")),
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,14 +159,16 @@ class WebberClient:
|
|||||||
agent_type: str,
|
agent_type: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
working_dir: str = ".",
|
working_dir: str = ".",
|
||||||
|
mode: PermissionMode = PermissionMode.default,
|
||||||
) -> AsyncIterator[str]:
|
) -> AsyncIterator[str]:
|
||||||
"""
|
"""
|
||||||
Run an agent with streaming response.
|
Run an agent with streaming response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_type: Type of agent (e.g., "explore")
|
agent_type: Type of agent (e.g., "task")
|
||||||
prompt: User prompt/query
|
prompt: User prompt/query
|
||||||
working_dir: Working directory for the agent
|
working_dir: Working directory for the agent
|
||||||
|
mode: Permission mode controlling tool access
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Text chunks as they arrive
|
Text chunks as they arrive
|
||||||
@@ -163,6 +185,7 @@ class WebberClient:
|
|||||||
"agent_type": agent_type,
|
"agent_type": agent_type,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
|
"mode": mode.value,
|
||||||
},
|
},
|
||||||
) as response:
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
+273
-110
@@ -5,7 +5,7 @@ Webber CLI - Client for the Webber API.
|
|||||||
Usage:
|
Usage:
|
||||||
webber-cli --help
|
webber-cli --help
|
||||||
webber-cli chat [OPTIONS]
|
webber-cli chat [OPTIONS]
|
||||||
webber-cli explore QUERY [OPTIONS]
|
webber-cli status
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
@@ -13,13 +13,87 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from rich.live import Live
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
||||||
|
from prompt_toolkit.completion import Completer, Completion, PathCompleter
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
from webber_cli.client import WebberClient
|
from webber_cli.client import WebberClient, PermissionMode
|
||||||
from webber_cli.theme import get_console, get_theme
|
from webber_cli.theme import get_console, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
# === Prompt Toolkit Setup ===
|
||||||
|
|
||||||
|
# History file location
|
||||||
|
HISTORY_FILE = Path.home() / ".webber_history"
|
||||||
|
|
||||||
|
# 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(HISTORY_FILE)),
|
||||||
|
auto_suggest=AutoSuggestFromHistory(),
|
||||||
|
completer=WebberCompleter(working_dir),
|
||||||
|
style=PROMPT_STYLE,
|
||||||
|
complete_while_typing=False, # Only complete on Tab
|
||||||
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="webber-cli",
|
name="webber-cli",
|
||||||
help="CLI client for the Webber API",
|
help="CLI client for the Webber API",
|
||||||
@@ -37,11 +111,28 @@ DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
|
|||||||
def version_callback(value: bool) -> None:
|
def version_callback(value: bool) -> None:
|
||||||
"""Display version and exit."""
|
"""Display version and exit."""
|
||||||
if value:
|
if value:
|
||||||
from cli import __version__
|
from webber_cli import __version__
|
||||||
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
@app.callback()
|
||||||
def main(
|
def main(
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
@@ -63,7 +154,7 @@ def chat(
|
|||||||
".",
|
".",
|
||||||
"--directory",
|
"--directory",
|
||||||
"-d",
|
"-d",
|
||||||
help="Working directory for exploration",
|
help="Working directory for the agent",
|
||||||
),
|
),
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
DEFAULT_API_URL,
|
DEFAULT_API_URL,
|
||||||
@@ -71,10 +162,11 @@ def chat(
|
|||||||
"-a",
|
"-a",
|
||||||
help="Webber API URL",
|
help="Webber API URL",
|
||||||
),
|
),
|
||||||
agent: str = typer.Option(
|
mode: str = typer.Option(
|
||||||
"explore",
|
"default",
|
||||||
"--agent",
|
"--mode",
|
||||||
help="Agent to use",
|
"-m",
|
||||||
|
help="Permission mode: default, plan (read-only), auto_accept (no prompts)",
|
||||||
),
|
),
|
||||||
stream: bool = typer.Option(
|
stream: bool = typer.Option(
|
||||||
True,
|
True,
|
||||||
@@ -84,9 +176,18 @@ def chat(
|
|||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Start interactive chat session.
|
Start interactive chat session with the Task agent.
|
||||||
|
|
||||||
Connects to the Webber API backend for agent execution.
|
The Task agent is the main orchestrator that can:
|
||||||
|
- Explore and analyze codebases
|
||||||
|
- Plan implementation strategies
|
||||||
|
- Execute code modifications (in default/auto_accept modes)
|
||||||
|
- Spawn sub-agents for focused tasks
|
||||||
|
|
||||||
|
Permission modes:
|
||||||
|
- default: Full capabilities with approval prompts for writes
|
||||||
|
- plan: Read-only mode for safe exploration and planning
|
||||||
|
- auto_accept: Full capabilities without approval prompts (use with caution)
|
||||||
"""
|
"""
|
||||||
working_dir = str(Path(directory).resolve())
|
working_dir = str(Path(directory).resolve())
|
||||||
|
|
||||||
@@ -94,17 +195,35 @@ def chat(
|
|||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Parse and validate mode
|
||||||
try:
|
try:
|
||||||
asyncio.run(_chat_loop(api_url, agent, working_dir, stream))
|
permission_mode = PermissionMode(mode)
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Error:[/] Invalid mode: {mode}")
|
||||||
|
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(api_url, working_dir, permission_mode, stream))
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[dim]Goodbye![/]")
|
console.print("\n[dim]Goodbye![/]")
|
||||||
|
|
||||||
|
|
||||||
async def _chat_loop(
|
async def _chat_loop(
|
||||||
api_url: str, agent_type: str, working_dir: str, stream: bool = True
|
api_url: str,
|
||||||
|
working_dir: str,
|
||||||
|
mode: PermissionMode,
|
||||||
|
stream: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Interactive chat loop."""
|
"""Interactive chat loop with the Task agent."""
|
||||||
theme = get_theme()
|
theme = get_theme()
|
||||||
|
agent_type = "task"
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
async with WebberClient(api_url) as client:
|
||||||
# Check API health
|
# Check API health
|
||||||
@@ -116,28 +235,45 @@ async def _chat_loop(
|
|||||||
# Get agent info
|
# Get agent info
|
||||||
agent_info = await client.get_agent(agent_type)
|
agent_info = await client.get_agent(agent_type)
|
||||||
if not agent_info:
|
if not agent_info:
|
||||||
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
|
console.print(f"[error]Error:[/] Task agent not found")
|
||||||
agents = await client.list_agents()
|
|
||||||
console.print("[dim]Available agents:[/]")
|
|
||||||
for a in agents:
|
|
||||||
console.print(f" - {a.name}: {a.description}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Mode display
|
||||||
|
mode_display = {
|
||||||
|
PermissionMode.default: "[info]default[/] (full with approvals)",
|
||||||
|
PermissionMode.plan: "[success]plan[/] (read-only)",
|
||||||
|
PermissionMode.auto_accept: "[warning]auto_accept[/] (no prompts)",
|
||||||
|
}
|
||||||
|
|
||||||
# Welcome message
|
# Welcome message
|
||||||
console.print()
|
console.print()
|
||||||
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
|
||||||
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
|
||||||
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
|
console.print(f"[dim]Mode:[/] {mode_display[mode]}")
|
||||||
mode = "streaming" if stream else "batch"
|
console.print(f"[dim]Streaming:[/] {'enabled' if stream else 'disabled'}")
|
||||||
console.print(f"[dim]Mode:[/] {mode}")
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
|
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear, 'mode <plan|default|auto_accept>' to switch[/]")
|
||||||
|
console.print("[dim]Tab for completion, Up/Down for history[/]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
current_mode = mode
|
||||||
|
|
||||||
|
# Create prompt session with history and completion
|
||||||
|
session = create_prompt_session(working_dir)
|
||||||
|
|
||||||
# Chat loop
|
# Chat loop
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
user_input = console.input("[prompt]>[/] ").strip()
|
# Use prompt_toolkit for input (with history and completion)
|
||||||
|
try:
|
||||||
|
user_input = await session.prompt_async(
|
||||||
|
[("class:prompt", "> ")],
|
||||||
|
)
|
||||||
|
user_input = user_input.strip()
|
||||||
|
except EOFError:
|
||||||
|
# Ctrl+D pressed
|
||||||
|
console.print("[dim]Goodbye![/]")
|
||||||
|
break
|
||||||
|
|
||||||
if not user_input:
|
if not user_input:
|
||||||
continue
|
continue
|
||||||
@@ -152,33 +288,52 @@ async def _chat_loop(
|
|||||||
|
|
||||||
if user_input.lower().startswith("cd "):
|
if user_input.lower().startswith("cd "):
|
||||||
new_dir = user_input[3:].strip()
|
new_dir = user_input[3:].strip()
|
||||||
new_path = Path(new_dir).resolve()
|
# Handle ~ expansion
|
||||||
|
new_path = Path(new_dir).expanduser().resolve()
|
||||||
if new_path.exists() and new_path.is_dir():
|
if new_path.exists() and new_path.is_dir():
|
||||||
working_dir = str(new_path)
|
working_dir = str(new_path)
|
||||||
|
# Update completer's working directory
|
||||||
|
session.completer.working_dir = working_dir
|
||||||
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
|
||||||
else:
|
else:
|
||||||
console.print(f"[error]Directory not found:[/] {new_dir}")
|
console.print(f"[error]Directory not found:[/] {new_dir}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Mode switching
|
||||||
|
if user_input.lower().startswith("mode "):
|
||||||
|
new_mode_str = user_input[5:].strip()
|
||||||
|
try:
|
||||||
|
new_mode = PermissionMode(new_mode_str)
|
||||||
|
if new_mode == PermissionMode.auto_accept:
|
||||||
|
if not _confirm_auto_accept():
|
||||||
|
console.print("[dim]Mode unchanged.[/]")
|
||||||
|
continue
|
||||||
|
current_mode = new_mode
|
||||||
|
console.print(f"[info]Mode changed to:[/] {mode_display[current_mode]}")
|
||||||
|
except ValueError:
|
||||||
|
console.print(f"[error]Invalid mode:[/] {new_mode_str}")
|
||||||
|
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
|
||||||
|
continue
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
# Stream response in real-time
|
# Stream response in real-time
|
||||||
full_response = ""
|
|
||||||
try:
|
try:
|
||||||
async for chunk in client.run_agent_stream(
|
async for chunk in client.run_agent_stream(
|
||||||
agent_type, user_input, working_dir
|
agent_type, user_input, working_dir, current_mode
|
||||||
):
|
):
|
||||||
sys.stdout.write(chunk)
|
sys.stdout.write(chunk)
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
full_response += chunk
|
|
||||||
console.print() # Newline after streaming
|
console.print() # Newline after streaming
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
console.print(f"\n[error]Stream error:[/] {e}")
|
||||||
else:
|
else:
|
||||||
# Batch mode with spinner
|
# Batch mode with spinner
|
||||||
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
|
||||||
result = await client.run_agent(agent_type, user_input, working_dir)
|
result = await client.run_agent(
|
||||||
|
agent_type, user_input, working_dir, current_mode
|
||||||
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
console.print(Markdown(result.response))
|
console.print(Markdown(result.response))
|
||||||
@@ -194,87 +349,6 @@ async def _chat_loop(
|
|||||||
console.print(f"[error]Error:[/] {e}")
|
console.print(f"[error]Error:[/] {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def explore(
|
|
||||||
query: str = typer.Argument(..., help="What to search for"),
|
|
||||||
directory: str = typer.Option(
|
|
||||||
".",
|
|
||||||
"--directory",
|
|
||||||
"-d",
|
|
||||||
help="Working directory",
|
|
||||||
),
|
|
||||||
api_url: str = typer.Option(
|
|
||||||
DEFAULT_API_URL,
|
|
||||||
"--api",
|
|
||||||
"-a",
|
|
||||||
help="Webber API URL",
|
|
||||||
),
|
|
||||||
stream: bool = typer.Option(
|
|
||||||
True,
|
|
||||||
"--stream/--no-stream",
|
|
||||||
"-s",
|
|
||||||
help="Stream responses in real-time",
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
One-shot codebase exploration.
|
|
||||||
|
|
||||||
Sends a query to the Webber API and displays the result.
|
|
||||||
"""
|
|
||||||
working_dir = str(Path(directory).resolve())
|
|
||||||
|
|
||||||
if not Path(working_dir).exists():
|
|
||||||
console.print(f"[error]Error:[/] Directory not found: {working_dir}")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
asyncio.run(_explore(api_url, query, working_dir, stream))
|
|
||||||
|
|
||||||
|
|
||||||
async def _explore(
|
|
||||||
api_url: str, query: str, working_dir: str, stream: bool = True
|
|
||||||
) -> None:
|
|
||||||
"""Execute exploration query."""
|
|
||||||
theme = get_theme()
|
|
||||||
|
|
||||||
async with WebberClient(api_url) as client:
|
|
||||||
# Check API health
|
|
||||||
if not await client.health_check():
|
|
||||||
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
|
|
||||||
console.print("[dim]Make sure the server is running: ./wakeup.sh[/]")
|
|
||||||
return
|
|
||||||
|
|
||||||
console.print(f"[dim]Exploring:[/] [path]{working_dir}[/]")
|
|
||||||
console.print(f"[dim]Query:[/] {query}")
|
|
||||||
console.print()
|
|
||||||
|
|
||||||
if stream:
|
|
||||||
# Stream response in real-time
|
|
||||||
try:
|
|
||||||
async for chunk in client.run_agent_stream("explore", query, working_dir):
|
|
||||||
sys.stdout.write(chunk)
|
|
||||||
sys.stdout.flush()
|
|
||||||
console.print() # Newline after streaming
|
|
||||||
except Exception as e:
|
|
||||||
console.print(f"\n[error]Stream error:[/] {e}")
|
|
||||||
else:
|
|
||||||
# Batch mode with spinner
|
|
||||||
with console.status("[info]Searching...[/]", spinner=theme.spinner):
|
|
||||||
result = await client.run_agent("explore", query, working_dir)
|
|
||||||
|
|
||||||
if result.success:
|
|
||||||
console.print(Panel(
|
|
||||||
Markdown(result.response),
|
|
||||||
title="[success]Findings[/]",
|
|
||||||
border_style=theme.colors.border_success,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
console.print(Panel(
|
|
||||||
f"[error]{result.error}[/]",
|
|
||||||
title="[error]Error[/]",
|
|
||||||
border_style=theme.colors.border_error,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def status(
|
def status(
|
||||||
api_url: str = typer.Option(
|
api_url: str = typer.Option(
|
||||||
@@ -304,5 +378,94 @@ async def _status(api_url: str) -> None:
|
|||||||
console.print("[error]Status:[/] Cannot connect")
|
console.print("[error]Status:[/] Cannot connect")
|
||||||
|
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
[DEPRECATED] One-shot exploration (use 'chat --mode plan' instead).
|
||||||
|
|
||||||
|
Runs the Task agent in plan (read-only) mode for a single query.
|
||||||
|
"""
|
||||||
|
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(api_url, query, working_dir, 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 in real-time
|
||||||
|
try:
|
||||||
|
async for chunk in client.run_agent_stream(
|
||||||
|
"task", query, working_dir, mode
|
||||||
|
):
|
||||||
|
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("task", query, working_dir, mode)
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
console.print(Panel(
|
||||||
|
Markdown(result.response),
|
||||||
|
title="[success]Findings[/]",
|
||||||
|
border_style=theme.colors.border_success,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
console.print(Panel(
|
||||||
|
f"[error]{result.error}[/]",
|
||||||
|
title="[error]Error[/]",
|
||||||
|
border_style=theme.colors.border_error,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
Reference in New Issue
Block a user