From b7956f88ed4688dfeb8ff556dd352d930a43c4ca Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 14 Jan 2026 08:48:07 +0100 Subject: [PATCH] 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 --- AGENTS.md | 15 +- CHANGELOG.md | 153 +------ webber-api/CHANGELOG.md | 150 +++++++ webber-api/src/domains/agents/approval.py | 212 ++++++++++ webber-api/src/domains/agents/router.py | 17 +- webber-api/src/domains/agents/schemas.py | 101 ++++- webber-api/src/domains/agents/task/agent.py | 90 ++-- webber-api/src/domains/agents/task/prompts.py | 60 ++- webber-api/src/domains/agents/task/tools.py | 174 ++++---- webber-api/tests/test_task_agent.py | 10 +- webber-cli/CHANGELOG.md | 21 + webber-cli/requirements.txt | 1 + webber-cli/webber_cli/client.py | 27 +- webber-cli/webber_cli/main.py | 383 +++++++++++++----- 14 files changed, 1047 insertions(+), 367 deletions(-) create mode 100644 webber-api/CHANGELOG.md create mode 100644 webber-api/src/domains/agents/approval.py create mode 100644 webber-cli/CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 6f58020..9587ea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 807983f..e9b117f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/webber-api/CHANGELOG.md b/webber-api/CHANGELOG.md new file mode 100644 index 0000000..848997d --- /dev/null +++ b/webber-api/CHANGELOG.md @@ -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) diff --git a/webber-api/src/domains/agents/approval.py b/webber-api/src/domains/agents/approval.py new file mode 100644 index 0000000..d56bbff --- /dev/null +++ b/webber-api/src/domains/agents/approval.py @@ -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 diff --git a/webber-api/src/domains/agents/router.py b/webber-api/src/domains/agents/router.py index d4a33c8..120d38d 100644 --- a/webber-api/src/domains/agents/router.py +++ b/webber-api/src/domains/agents/router.py @@ -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}") diff --git a/webber-api/src/domains/agents/schemas.py b/webber-api/src/domains/agents/schemas.py index 8eca1bb..f935149 100644 --- a/webber-api/src/domains/agents/schemas.py +++ b/webber-api/src/domains/agents/schemas.py @@ -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): diff --git a/webber-api/src/domains/agents/task/agent.py b/webber-api/src/domains/agents/task/agent.py index 3df633c..3ee4989 100644 --- a/webber-api/src/domains/agents/task/agent.py +++ b/webber-api/src/domains/agents/task/agent.py @@ -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: diff --git a/webber-api/src/domains/agents/task/prompts.py b/webber-api/src/domains/agents/task/prompts.py index 25949e7..12c756a 100644 --- a/webber-api/src/domains/agents/task/prompts.py +++ b/webber-api/src/domains/agents/task/prompts.py @@ -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. diff --git a/webber-api/src/domains/agents/task/tools.py b/webber-api/src/domains/agents/task/tools.py index 5d6f672..71b769f 100644 --- a/webber-api/src/domains/agents/task/tools.py +++ b/webber-api/src/domains/agents/task/tools.py @@ -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) diff --git a/webber-api/tests/test_task_agent.py b/webber-api/tests/test_task_agent.py index 11426e0..5ed18dd 100644 --- a/webber-api/tests/test_task_agent.py +++ b/webber-api/tests/test_task_agent.py @@ -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: diff --git a/webber-cli/CHANGELOG.md b/webber-cli/CHANGELOG.md new file mode 100644 index 0000000..94bdc49 --- /dev/null +++ b/webber-cli/CHANGELOG.md @@ -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 diff --git a/webber-cli/requirements.txt b/webber-cli/requirements.txt index c51c808..31951d1 100644 --- a/webber-cli/requirements.txt +++ b/webber-cli/requirements.txt @@ -2,3 +2,4 @@ httpx~=0.28.1 typer~=0.15.0 rich~=13.9.0 +prompt_toolkit~=3.0.48 diff --git a/webber-cli/webber_cli/client.py b/webber-cli/webber_cli/client.py index d5ff1a5..d111ad0 100644 --- a/webber-cli/webber_cli/client.py +++ b/webber-cli/webber_cli/client.py @@ -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() diff --git a/webber-cli/webber_cli/main.py b/webber-cli/webber_cli/main.py index 277f427..3f6f1f9 100644 --- a/webber-cli/webber_cli/main.py +++ b/webber-cli/webber_cli/main.py @@ -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 ' 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()