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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
+6
-147
@@ -1,151 +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.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 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -16,6 +21,7 @@ from src.domains.agents.schemas import (
|
||||
AgentRunResponse,
|
||||
AgentInfo,
|
||||
AgentListResponse,
|
||||
PermissionMode,
|
||||
)
|
||||
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.
|
||||
|
||||
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)
|
||||
@@ -50,15 +57,17 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
# Run the agent
|
||||
# Run the agent with mode
|
||||
response = await agent.run(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.mode,
|
||||
)
|
||||
|
||||
return AgentRunResponse(
|
||||
response=response,
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
success=True,
|
||||
)
|
||||
|
||||
@@ -67,6 +76,7 @@ async def run_agent(request: AgentRunRequest) -> AgentRunResponse:
|
||||
return AgentRunResponse(
|
||||
response="",
|
||||
agent_type=request.agent_type,
|
||||
mode=request.mode,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
@@ -79,6 +89,8 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
Run an agent with streaming response.
|
||||
|
||||
Returns Server-Sent Events (SSE) with text chunks.
|
||||
Permission mode controls which tools are available.
|
||||
|
||||
Event types:
|
||||
- "chunk": Text chunk from the agent
|
||||
- "done": Stream complete
|
||||
@@ -96,13 +108,14 @@ async def stream_agent(request: AgentRunRequest) -> StreamingResponse:
|
||||
async for chunk in agent.run_stream(
|
||||
request.prompt,
|
||||
working_dir=request.working_dir,
|
||||
mode=request.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"
|
||||
yield f"data: {json.dumps({'event': 'done', 'mode': request.mode.value})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Stream error: {e}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -3,19 +3,20 @@ 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
|
||||
"""
|
||||
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
|
||||
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 +30,21 @@ 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.
|
||||
"""
|
||||
pass
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
# Prep for approval flow - tools can check this
|
||||
pending_approvals: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
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 +55,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 +78,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 +95,21 @@ 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 import register_task_tools, register_readonly_tools
|
||||
|
||||
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()
|
||||
async def run(
|
||||
@@ -94,6 +117,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,6 +127,7 @@ 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
|
||||
@@ -111,12 +136,15 @@ class TaskAgentImpl(BaseAgent):
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
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:
|
||||
# Use run() not run_stream() - Ollama has bugs with streaming + tools
|
||||
result = await self.agent.run(prompt, deps=ctx)
|
||||
result = await agent.run(prompt, deps=ctx)
|
||||
return result.output
|
||||
except Exception as e:
|
||||
logger.exception(f"Task agent error: {e}")
|
||||
@@ -127,22 +155,34 @@ class TaskAgentImpl(BaseAgent):
|
||||
prompt: str,
|
||||
working_dir: str | None = None,
|
||||
allowed_paths: list[str] | None = None,
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
**kwargs: Any
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
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(
|
||||
working_dir=working_dir or os.getcwd(),
|
||||
allowed_paths=allowed_paths or self._settings.effective_allowed_paths,
|
||||
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:
|
||||
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():
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,10 +3,68 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
You have access to ALL tools including file editing, writing, and bash execution.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
typer~=0.15.0
|
||||
rich~=13.9.0
|
||||
prompt_toolkit~=3.0.48
|
||||
|
||||
@@ -2,20 +2,36 @@
|
||||
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 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"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResponse:
|
||||
"""Response from agent execution."""
|
||||
response: str
|
||||
agent_type: str
|
||||
success: bool
|
||||
mode: PermissionMode = PermissionMode.default
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -104,14 +120,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 +141,7 @@ class WebberClient:
|
||||
"agent_type": agent_type,
|
||||
"prompt": prompt,
|
||||
"working_dir": working_dir,
|
||||
"mode": mode.value,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -131,6 +150,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,14 +159,16 @@ class WebberClient:
|
||||
agent_type: str,
|
||||
prompt: str,
|
||||
working_dir: str = ".",
|
||||
mode: PermissionMode = PermissionMode.default,
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
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
|
||||
@@ -163,6 +185,7 @@ class WebberClient:
|
||||
"agent_type": agent_type,
|
||||
"prompt": prompt,
|
||||
"working_dir": working_dir,
|
||||
"mode": mode.value,
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
+273
-110
@@ -5,7 +5,7 @@ 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
|
||||
@@ -13,13 +13,87 @@ 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
|
||||
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(
|
||||
name="webber-cli",
|
||||
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:
|
||||
"""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()
|
||||
|
||||
|
||||
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()
|
||||
def main(
|
||||
version: bool = typer.Option(
|
||||
@@ -63,7 +154,7 @@ def chat(
|
||||
".",
|
||||
"--directory",
|
||||
"-d",
|
||||
help="Working directory for exploration",
|
||||
help="Working directory for the agent",
|
||||
),
|
||||
api_url: str = typer.Option(
|
||||
DEFAULT_API_URL,
|
||||
@@ -71,10 +162,11 @@ def chat(
|
||||
"-a",
|
||||
help="Webber API URL",
|
||||
),
|
||||
agent: str = typer.Option(
|
||||
"explore",
|
||||
"--agent",
|
||||
help="Agent to use",
|
||||
mode: str = typer.Option(
|
||||
"default",
|
||||
"--mode",
|
||||
"-m",
|
||||
help="Permission mode: default, plan (read-only), auto_accept (no prompts)",
|
||||
),
|
||||
stream: bool = typer.Option(
|
||||
True,
|
||||
@@ -84,9 +176,18 @@ def chat(
|
||||
),
|
||||
) -> 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())
|
||||
|
||||
@@ -94,17 +195,35 @@ def chat(
|
||||
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)
|
||||
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:
|
||||
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,
|
||||
) -> None:
|
||||
"""Interactive chat loop."""
|
||||
"""Interactive chat loop with the Task agent."""
|
||||
theme = get_theme()
|
||||
agent_type = "task"
|
||||
|
||||
async with WebberClient(api_url) as client:
|
||||
# Check API health
|
||||
@@ -116,28 +235,45 @@ 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
|
||||
|
||||
# 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,33 +288,52 @@ 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 = ""
|
||||
try:
|
||||
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.flush()
|
||||
full_response += chunk
|
||||
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]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))
|
||||
@@ -194,87 +349,6 @@ 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(
|
||||
@@ -304,5 +378,94 @@ async def _status(api_url: str) -> None:
|
||||
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__":
|
||||
app()
|
||||
|
||||
Reference in New Issue
Block a user