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:
2026-01-14 08:48:07 +01:00
co-authored by Claude Opus 4.5
parent acf231eb66
commit b7956f88ed
14 changed files with 1047 additions and 367 deletions
+12 -3
View File
@@ -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
View File
@@ -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
+150
View File
@@ -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)
+212
View File
@@ -0,0 +1,212 @@
"""
Tool approval evaluation logic.
Provides granular control over tool execution:
- Rule-based matching on tool name and arguments
- Priority-ordered rule evaluation
- Default fallback behavior
"""
import re
from typing import Any
from src.domains.agents.schemas import (
ApprovalAction,
ApprovalRule,
ApprovalRuleSet,
PermissionMode,
)
from src.shared.logging import get_logger
logger = get_logger(__name__)
def _serialize_tool_args(tool_args: dict[str, Any]) -> str:
"""
Serialize tool arguments to a string for pattern matching.
Converts tool args dict to a consistent string format that can be
matched against regex patterns.
Examples:
{"command": "curl localhost:8095"} -> "command=curl localhost:8095"
{"file_path": "/src/main.py"} -> "file_path=/src/main.py"
"""
parts = []
for key, value in sorted(tool_args.items()):
parts.append(f"{key}={value}")
return " ".join(parts)
def evaluate_rule(rule: ApprovalRule, tool_name: str, tool_args: dict[str, Any]) -> bool:
"""
Check if a rule matches the given tool call.
Args:
rule: The approval rule to evaluate
tool_name: Name of the tool being called
tool_args: Arguments passed to the tool
Returns:
True if the rule matches, False otherwise
"""
# Tool name must match exactly
if rule.tool != tool_name and rule.tool != "*":
return False
# Serialize args for pattern matching
args_str = _serialize_tool_args(tool_args)
# Try to match pattern against serialized args
try:
if re.search(rule.pattern, args_str, re.IGNORECASE):
return True
except re.error as e:
logger.warning(f"Invalid regex pattern in rule: {rule.pattern} - {e}")
return False
return False
def evaluate_approval(
ruleset: ApprovalRuleSet,
tool_name: str,
tool_args: dict[str, Any],
mode: PermissionMode = PermissionMode.default,
) -> ApprovalAction:
"""
Evaluate whether a tool call should be allowed, denied, or prompt for approval.
Args:
ruleset: Set of approval rules to evaluate
tool_name: Name of the tool being called
tool_args: Arguments passed to the tool
mode: Current permission mode
Returns:
ApprovalAction indicating what to do (allow, deny, ask)
"""
# Plan mode: only read-only tools are even registered, so if we get here
# it's a read-only tool and should be allowed
if mode == PermissionMode.plan:
return ApprovalAction.allow
# Auto-accept mode: allow everything without prompting
if mode == PermissionMode.auto_accept:
return ApprovalAction.allow
# Default mode: evaluate rules
# Sort rules by priority (highest first)
sorted_rules = sorted(ruleset.rules, key=lambda r: r.priority, reverse=True)
for rule in sorted_rules:
if evaluate_rule(rule, tool_name, tool_args):
logger.debug(
f"Rule matched: {rule.description or rule.pattern} -> {rule.action}"
)
return rule.action
# No rules matched, use default action
return ruleset.default_action
# === Default rule sets ===
# Read-only tools that never need approval
READONLY_TOOLS = {"read_file", "glob_files", "grep_content", "bash_readonly"}
# Default rules for common patterns
DEFAULT_RULES = ApprovalRuleSet(
rules=[
# Always allow read-only tools
ApprovalRule(
tool="read_file",
pattern=".*",
action=ApprovalAction.allow,
description="Allow all file reads",
priority=100,
),
ApprovalRule(
tool="glob_files",
pattern=".*",
action=ApprovalAction.allow,
description="Allow all glob searches",
priority=100,
),
ApprovalRule(
tool="grep_content",
pattern=".*",
action=ApprovalAction.allow,
description="Allow all grep searches",
priority=100,
),
ApprovalRule(
tool="bash_readonly",
pattern=".*",
action=ApprovalAction.allow,
description="Allow all read-only bash commands",
priority=100,
),
# Dangerous patterns - always deny
ApprovalRule(
tool="bash",
pattern="rm\\s+-rf\\s+/",
action=ApprovalAction.deny,
description="Deny recursive delete from root",
priority=90,
),
ApprovalRule(
tool="bash",
pattern="sudo\\s+",
action=ApprovalAction.deny,
description="Deny sudo commands",
priority=90,
),
# Common safe patterns - allow without prompting
ApprovalRule(
tool="bash",
pattern="command=git\\s+(status|log|diff|show|branch)",
action=ApprovalAction.allow,
description="Allow read-only git commands",
priority=50,
),
ApprovalRule(
tool="bash",
pattern="command=pytest\\s+",
action=ApprovalAction.allow,
description="Allow pytest execution",
priority=50,
),
ApprovalRule(
tool="bash",
pattern="command=python\\s+-m\\s+pytest",
action=ApprovalAction.allow,
description="Allow pytest via python -m",
priority=50,
),
ApprovalRule(
tool="bash",
pattern="command=curl.*localhost",
action=ApprovalAction.allow,
description="Allow curl to localhost",
priority=50,
),
ApprovalRule(
tool="bash",
pattern="command=curl.*127\\.0\\.0\\.1",
action=ApprovalAction.allow,
description="Allow curl to 127.0.0.1",
priority=50,
),
],
default_action=ApprovalAction.ask,
)
def get_default_ruleset() -> ApprovalRuleSet:
"""Get the default approval ruleset."""
return DEFAULT_RULES
def is_readonly_tool(tool_name: str) -> bool:
"""Check if a tool is read-only (never needs approval)."""
return tool_name in READONLY_TOOLS
+15 -2
View File
@@ -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}")
+100 -1
View File
@@ -1,22 +1,121 @@
"""
Request and response schemas for agent API.
"""
from enum import Enum
from src.shared.base import BaseSchema
class PermissionMode(str, Enum):
"""
Permission modes that control agent tool access.
Aligns with Claude Code's permission model:
- default: Full tools, approval required for writes (future)
- plan: Read-only tools only, no approval needed
- auto_accept: Full tools, no approval prompts
"""
default = "default"
plan = "plan"
auto_accept = "auto_accept"
class ApprovalStatus(str, Enum):
"""Status of a tool approval request."""
pending = "pending"
approved = "approved"
denied = "denied"
class ApprovalAction(str, Enum):
"""Action to take when a rule matches."""
allow = "allow" # Auto-approve without prompting
deny = "deny" # Auto-deny without prompting
ask = "ask" # Prompt user for approval
class ApprovalRule(BaseSchema):
"""
Granular approval rule for tool execution.
Allows fine-grained control over which tool calls are allowed:
- Pattern matching on tool arguments
- Different actions per rule (allow, deny, ask)
Examples:
# Allow curl to localhost
ApprovalRule(tool="bash", pattern="curl.*localhost.*", action="allow")
# Deny any rm command
ApprovalRule(tool="bash", pattern="rm\\s+.*", action="deny")
# Ask for git push
ApprovalRule(tool="bash", pattern="git\\s+push.*", action="ask")
# Allow all file reads in src/
ApprovalRule(tool="read_file", pattern=".*/src/.*", action="allow")
"""
tool: str # Tool name to match (e.g., "bash", "edit_file")
pattern: str # Regex pattern to match against tool args
action: ApprovalAction # What to do when matched
description: str | None = None # Human-readable description of rule
priority: int = 0 # Higher priority rules evaluated first
class ApprovalRuleSet(BaseSchema):
"""
Collection of approval rules with evaluation logic.
Rules are evaluated in priority order (highest first).
First matching rule determines the action.
If no rules match, falls back to default action.
"""
rules: list[ApprovalRule] = []
default_action: ApprovalAction = ApprovalAction.ask # Default when no rules match
class ToolApprovalRequest(BaseSchema):
"""
Request for tool execution approval.
Sent from API to CLI when a tool needs user approval.
Prep for future bidirectional approval flow.
"""
request_id: str
tool_name: str
tool_args: dict
description: str
risk_level: str = "write" # "read", "write", "dangerous"
class ToolApprovalResponse(BaseSchema):
"""
Response to a tool approval request.
Sent from CLI to API with user's decision.
"""
request_id: str
status: ApprovalStatus
reason: str | None = None
class AgentRunRequest(BaseSchema):
"""Request to run an agent."""
prompt: str
working_dir: str = "."
agent_type: str = "explore"
agent_type: str = "task" # Default to task agent (main agent)
mode: PermissionMode = PermissionMode.default
class AgentRunResponse(BaseSchema):
"""Response from agent execution."""
response: str
agent_type: str
mode: PermissionMode = PermissionMode.default
success: bool = True
error: str | None = None
# Prep for approval flow - if set, CLI should handle approval
pending_approval: ToolApprovalRequest | None = None
class AgentInfo(BaseSchema):
+65 -25
View File
@@ -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:
+59 -1
View File
@@ -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.
+103 -71
View File
@@ -1,11 +1,9 @@
"""
Tool registrations for the Task agent.
The Task agent has access to ALL tools:
- Read-only tools (same as Explore/Plan)
- Write tools (edit, write, bash full)
- External tools (web search)
- Orchestration (spawn sub-agents)
The Task agent has access to tools based on permission mode:
- Plan mode: Read-only tools only
- Default/auto_accept: All tools including write operations
"""
from pydantic_ai import Agent, RunContext
@@ -20,19 +18,8 @@ from src.domains.tools.shell.bash import BashReadOnlyTool
from src.domains.tools.shell.bash_full import BashTool
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
"""
Register all tools with the Task agent.
Includes:
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
- Write tools: edit_file, write_file, bash
- External: web_search
- Orchestration: spawn_agent
"""
# === Read-only tools ===
def _register_read_file(agent: Agent[AgentContext, str]) -> None:
"""Register read_file tool."""
@agent.tool
async def read_file(
ctx: RunContext[AgentContext],
@@ -60,6 +47,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
return result.to_string()
def _register_glob_files(agent: Agent[AgentContext, str]) -> None:
"""Register glob_files tool."""
@agent.tool
async def glob_files(
ctx: RunContext[AgentContext],
@@ -91,6 +81,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
return result.to_string()
def _register_grep_content(agent: Agent[AgentContext, str]) -> None:
"""Register grep_content tool."""
@agent.tool
async def grep_content(
ctx: RunContext[AgentContext],
@@ -124,6 +117,9 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
return result.to_string()
def _register_bash_readonly(agent: Agent[AgentContext, str]) -> None:
"""Register bash_readonly tool."""
@agent.tool
async def bash_readonly(
ctx: RunContext[AgentContext],
@@ -159,6 +155,94 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
)
return result.to_string()
def _register_spawn_agent(agent: Agent[AgentContext, str], readonly_only: bool = False) -> None:
"""Register spawn_agent tool."""
@agent.tool
async def spawn_agent(
ctx: RunContext[AgentContext],
agent_type: str,
prompt: str,
working_dir: str | None = None
) -> str:
"""Spawn a sub-agent to handle a focused task.
Use this to offload work to specialized agents:
- "explore": Fast codebase searches and analysis (read-only)
- "plan": Design implementation strategies (read-only)
Args:
agent_type: Type of agent to spawn ("explore" or "plan")
prompt: Task description for the sub-agent
working_dir: Working directory for the sub-agent (default: current)
Returns:
Sub-agent's consolidated response.
Examples:
- spawn_agent(agent_type="explore", prompt="find all test files")
- spawn_agent(agent_type="plan", prompt="design user auth feature")
IMPORTANT:
- Use sub-agents to keep context focused and efficient
- Explore agent for research, Plan agent for design
- Cannot spawn nested Task agents (recursion risk)
"""
from src.domains.agents.base import get_agent
# Validate agent type
allowed_types = ["explore", "plan"]
if agent_type not in allowed_types:
if agent_type == "task":
return "Error: Cannot spawn nested Task agents (recursion risk)"
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
sub_agent = get_agent(agent_type)
if not sub_agent:
return f"Error: Agent '{agent_type}' not found in registry"
try:
result = await sub_agent.run(
prompt=prompt,
working_dir=working_dir or ctx.deps.working_dir,
allowed_paths=ctx.deps.allowed_paths,
)
return result
except Exception as e:
return f"Sub-agent error: {e}"
def register_readonly_tools(agent: Agent[AgentContext, str]) -> None:
"""
Register read-only tools with the agent.
Used in plan mode. Includes:
- read_file, glob_files, grep_content, bash_readonly
- spawn_agent (restricted to explore/plan)
"""
_register_read_file(agent)
_register_glob_files(agent)
_register_grep_content(agent)
_register_bash_readonly(agent)
_register_spawn_agent(agent, readonly_only=True)
def register_task_tools(agent: Agent[AgentContext, str]) -> None:
"""
Register all tools with the Task agent.
Includes:
- Read-only tools: read_file, glob_files, grep_content, bash_readonly
- Write tools: edit_file, write_file, bash
- External: web_search
- Orchestration: spawn_agent
"""
# Register read-only tools via helpers
_register_read_file(agent)
_register_glob_files(agent)
_register_grep_content(agent)
_register_bash_readonly(agent)
# === Write tools ===
@agent.tool
@@ -295,56 +379,4 @@ def register_task_tools(agent: Agent[AgentContext, str]) -> None:
return result.to_string()
# === Orchestration tools ===
@agent.tool
async def spawn_agent(
ctx: RunContext[AgentContext],
agent_type: str,
prompt: str,
working_dir: str | None = None
) -> str:
"""Spawn a sub-agent to handle a focused task.
Use this to offload work to specialized agents:
- "explore": Fast codebase searches and analysis (read-only)
- "plan": Design implementation strategies (read-only)
Args:
agent_type: Type of agent to spawn ("explore" or "plan")
prompt: Task description for the sub-agent
working_dir: Working directory for the sub-agent (default: current)
Returns:
Sub-agent's consolidated response.
Examples:
- spawn_agent(agent_type="explore", prompt="find all test files")
- spawn_agent(agent_type="plan", prompt="design user auth feature")
IMPORTANT:
- Use sub-agents to keep context focused and efficient
- Explore agent for research, Plan agent for design
- Cannot spawn nested Task agents (recursion risk)
"""
from src.domains.agents.base import get_agent
# Validate agent type
allowed_types = ["explore", "plan"]
if agent_type not in allowed_types:
if agent_type == "task":
return "Error: Cannot spawn nested Task agents (recursion risk)"
return f"Error: Unknown agent type '{agent_type}'. Allowed: {allowed_types}"
sub_agent = get_agent(agent_type)
if not sub_agent:
return f"Error: Agent '{agent_type}' not found in registry"
try:
result = await sub_agent.run(
prompt=prompt,
working_dir=working_dir or ctx.deps.working_dir,
allowed_paths=ctx.deps.allowed_paths,
)
return result
except Exception as e:
return f"Sub-agent error: {e}"
_register_spawn_agent(agent, readonly_only=False)
+5 -5
View File
@@ -206,14 +206,14 @@ class TestTaskAgentProperties:
# Create a fresh instance
fresh_agent = TaskAgentImpl()
# _agent should be None before first access
assert fresh_agent._agent is None
# _agents dict should be empty before first access
assert len(fresh_agent._agents) == 0
# Access the agent property
# Access the agent property (creates default mode agent)
_ = fresh_agent.agent
# Now _agent should be set
assert fresh_agent._agent is not None
# Now _agents should have one entry
assert len(fresh_agent._agents) == 1
class TestAllAgentsRegistered:
+21
View File
@@ -0,0 +1,21 @@
# Changelog - Webber CLI
All notable changes to the Webber CLI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-01-10
### Added
- Initial CLI release as part of monorepo reorganization
- Typer + Rich foundation with console theming
- Commands:
- `webber-cli status` - Check API connection
- `webber-cli explore` - One-shot codebase exploration
- `webber-cli chat` - Interactive conversation mode
- Streaming support with `--stream` flag (default: enabled)
- Markdown rendering for agent responses
- Configurable API URL via `WEBBER_API_URL` environment variable
+1
View File
@@ -2,3 +2,4 @@
httpx~=0.28.1
typer~=0.15.0
rich~=13.9.0
prompt_toolkit~=3.0.48
+25 -2
View File
@@ -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
View File
@@ -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()