2 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 daa9543790 feat: add session persistence to CLI
- Add save-only endpoint (POST /conversations/{id}/save) for persisting
  messages without triggering agent execution
- Add sessions command to list previous conversation sessions
- Add --resume flag to chat command for resuming sessions by ID
- Buffer streamed responses and save after completion
- Update AGENTS.md with session commands and remove outdated limitation
- Add 3 new tests for save endpoint (208 total)

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:48:07 +01:00
18 changed files with 1622 additions and 388 deletions
+35 -11
View File
@@ -88,13 +88,29 @@ cd webber-cli
# Check API connection
.venv/bin/webber-cli status
# Explore a directory
.venv/bin/webber-cli explore "find all python files" -d ../webber-sandbox
# Interactive chat mode
# Interactive chat (default mode - full capabilities)
.venv/bin/webber-cli chat -d ../webber-sandbox
# Read-only mode (safe exploration and planning)
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
# Auto-accept mode (no approval prompts - use with caution)
.venv/bin/webber-cli chat --mode auto_accept -d ../webber-sandbox
# List previous sessions
.venv/bin/webber-cli sessions
# Resume a previous session
.venv/bin/webber-cli chat --resume <session-id>
```
**CLI Features:**
- **Tab completion** for commands and file paths
- **Command history** persisted to `~/.webber_history`
- **Session persistence** - conversations saved and resumable
- **Runtime mode switching** via `mode plan|default|auto_accept`
- **Directory navigation** via `cd <path>`
**Note:** The API server must be running for CLI commands to work.
---
@@ -153,9 +169,10 @@ pytest tests/ -v
# 1. Load the template
./sandbox.sh load calculator-cli
# 2. Have Webber explore it
# 2. Have Webber explore it (plan mode = read-only)
cd webber-cli
.venv/bin/webber-cli explore "find all bugs in the code" -d ../webber-sandbox
.venv/bin/webber-cli chat --mode plan -d ../webber-sandbox
# Then ask: "find all bugs in the code"
# 3. Check TASKS.md for expected bugs
cat ../webber-sandbox/TASKS.md
@@ -192,14 +209,23 @@ Uses prefixed tags:
**NEVER push a tag before updating version files.** Follow this exact order:
```bash
# 1. Update version in pyproject.toml
# 2. Update CHANGELOG.md with release notes
# For API releases:
# 1. Update version in webber-api/pyproject.toml
# 2. Update webber-api/CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release api vX.Y.Z"
# 4. Create the tag (AFTER the commit)
git tag api/vX.Y.Z
# 5. Push everything together
git push origin main --tags
# For CLI releases:
# 1. Update version in webber-cli/pyproject.toml
# 2. Update webber-cli/CHANGELOG.md with release notes
# 3. Commit the version bump
git add -A && git commit -m "chore: release cli vX.Y.Z"
# 4. Create the tag (AFTER the commit)
git tag cli/vX.Y.Z
# 5. Push everything together
git push origin main --tags
```
@@ -249,7 +275,5 @@ cd webber-api
## Known Limitations
1. **Model hallucination** - Mistral Nemo sometimes makes up file contents instead of using tool results
2. **No conversation memory** - CLI chat mode doesn't persist between sessions
3. **No streaming** - Responses appear all at once
See `webber-api/docs/COVERAGE.md` for full feature coverage status.
+6 -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)
+12 -10
View File
@@ -2,9 +2,9 @@
> Tracking progress towards Claude Code-like functionality
## Current Status: ~80% Complete
## Current Status: ~85% Complete
Last updated: 2026-01-11
Last updated: 2026-01-14
---
@@ -122,11 +122,13 @@ Last updated: 2026-01-11
|---------|----------|-------------|------------|
| **Web search summarizer** | Tools | Agent to extract core content from web pages (remove nav, footers, etc.) and preserve relevant links for nested fetching | Medium |
| **Tool result caching** | Infrastructure | Cache file reads for performance | Low |
| **Session persistence** | CLI | Save/resume conversations | Medium |
| ~~**Session persistence**~~ | CLI | ✅ Save/resume conversations via `sessions` and `chat --resume` | Medium |
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
| **Git integration** | CLI | Auto-commit, branch management | Medium |
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
| ~~**Agent handoff**~~ | Orchestration | ✅ Task agent is main agent, spawns Explore/Plan as needed (Claude Code pattern) | High |
| ~~**Retry logic**~~ | Infrastructure | ✅ Auto-retry with exponential backoff | Low |
| ~~**Permission modes**~~ | CLI | ✅ default/plan/auto_accept modes controlling tool access | Medium |
| ~~**CLI shell features**~~ | CLI | ✅ prompt_toolkit: history, tab completion, auto-suggest | Low |
### Low Priority
@@ -149,14 +151,14 @@ Last updated: 2026-01-11
| API tests | 11 | 11 | ✅ |
| Plan agent tests | 15 | 15 | ✅ |
| Task agent tests | 15 | 15 | ✅ |
| Conversation tests | 19 | 19 | ✅ |
| Conversation tests | 22 | 22 | ✅ |
| Token tests | 6 | 6 | ✅ |
| Retry tests | 29 | 29 | ✅ |
| Security tests | 14 | 14 | ✅ |
| Integration tests | 10 | 10 | ✅ Agent + real LLM |
| E2E tests | 12 | 12 | ✅ Full API workflow |
**Total: 205 tests passing**
**Total: 208 tests passing**
**Test breakdown:**
- Read/Glob/Grep tools: 17 tests
@@ -167,7 +169,7 @@ Last updated: 2026-01-11
- API endpoints: 11 tests
- Plan agent: 15 tests
- Task agent: 15 tests
- Conversations: 19 tests
- Conversations: 22 tests
- Tokens: 6 tests
- Retry: 29 tests
- Security: 14 tests
@@ -224,9 +226,9 @@ cd webber-api && ./wakeup.sh
# CLI commands (from webber-cli/)
.venv/bin/webber-cli status # Check API connection
.venv/bin/webber-cli explore "find tests" # One-shot exploration
.venv/bin/webber-cli explore "query" --no-stream # Batch mode
.venv/bin/webber-cli chat # Interactive mode
.venv/bin/webber-cli chat # Interactive mode (Task agent, full tools)
.venv/bin/webber-cli chat --mode plan # Read-only mode (safe exploration)
.venv/bin/webber-cli chat --mode auto_accept # No approval prompts (use with caution)
# API endpoints
curl http://localhost:8095/health
+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)
@@ -15,6 +15,8 @@ from src.domains.conversations.schemas import (
ConversationResponse,
CreateConversationRequest,
MessageResponse,
SaveMessagesRequest,
SaveMessagesResponse,
)
from src.domains.conversations.service import ConversationService
from src.shared.auth import require_auth
@@ -187,3 +189,51 @@ async def add_message(
total_tokens=conversation.total_tokens if conversation else 0,
summarized=summarized,
)
@router.post("/{conversation_id}/save", response_model=SaveMessagesResponse)
@logged()
async def save_messages(
conversation_id: UUID,
request: SaveMessagesRequest,
session: AsyncSession = Depends(get_session),
user=Depends(require_auth),
) -> SaveMessagesResponse:
"""
Save a user/assistant message pair without triggering agent execution.
Used by CLI when streaming responses separately via /agents/stream.
This allows persisting the exchange after streaming completes.
"""
service = ConversationService(session)
# Verify conversation exists and user owns it
conversation = await service.get(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="Conversation not found")
if conversation.user_id != user.id:
raise HTTPException(status_code=403, detail="Not authorized")
# Save user message
user_message = await service.add_message(
conversation_id=conversation_id,
role="user",
content=request.user_content,
)
# Save assistant message
assistant_message = await service.add_message(
conversation_id=conversation_id,
role="assistant",
content=request.assistant_content,
)
# Get updated conversation for total tokens
conversation = await service.get(conversation_id)
return SaveMessagesResponse(
user_message=MessageResponse.model_validate(user_message),
assistant_message=MessageResponse.model_validate(assistant_message),
total_tokens=conversation.total_tokens if conversation else 0,
)
@@ -21,6 +21,15 @@ class AddMessageRequest(BaseModel):
content: str = Field(..., min_length=1, description="Message content")
class SaveMessagesRequest(BaseModel):
"""Request to save a message pair without triggering agent execution.
Used by CLI when streaming responses separately via /agents/stream.
"""
user_content: str = Field(..., min_length=1, description="User message content")
assistant_content: str = Field(..., min_length=1, description="Assistant response content")
# === Response Schemas ===
class MessageResponse(BaseModel):
@@ -77,3 +86,10 @@ class AddMessageResponse(BaseModel):
default=False,
description="Whether context was summarized due to token limit"
)
class SaveMessagesResponse(BaseModel):
"""Response after saving messages (no agent execution)."""
user_message: MessageResponse
assistant_message: MessageResponse
total_tokens: int
+84
View File
@@ -297,3 +297,87 @@ class TestSummarization:
assert "[Previous Summary]" in formatted
assert "Previous context summary" in formatted
class TestSaveMessagesAPI:
"""Tests for the save messages endpoint (no agent execution)."""
@pytest.mark.anyio
async def test_save_messages(self, auth_client):
"""Test saving a message pair without triggering agent."""
# First create a conversation
response = await auth_client.post(
"/conversations/",
json={"agent_type": "task", "working_dir": "."}
)
assert response.status_code == 201
conv_id = response.json()["id"]
# Save a message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "Find all Python files",
"assistant_content": "I found 5 Python files in the project.",
}
)
assert response.status_code == 200
data = response.json()
assert data["user_message"]["role"] == "user"
assert data["user_message"]["content"] == "Find all Python files"
assert data["assistant_message"]["role"] == "assistant"
assert data["assistant_message"]["content"] == "I found 5 Python files in the project."
assert data["total_tokens"] > 0
@pytest.mark.anyio
async def test_save_messages_not_found(self, auth_client):
"""Test saving messages to non-existent conversation."""
fake_id = uuid4()
response = await auth_client.post(
f"/conversations/{fake_id}/save",
json={
"user_content": "Test",
"assistant_content": "Response",
}
)
assert response.status_code == 404
@pytest.mark.anyio
async def test_save_messages_updates_token_count(self, auth_client):
"""Test that saving messages updates the conversation token count."""
# Create conversation
response = await auth_client.post(
"/conversations/",
json={"agent_type": "explore", "working_dir": "."}
)
conv_id = response.json()["id"]
assert response.json()["total_tokens"] == 0
# Save first message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "Hello",
"assistant_content": "Hi there!",
}
)
first_tokens = response.json()["total_tokens"]
assert first_tokens > 0
# Save second message pair
response = await auth_client.post(
f"/conversations/{conv_id}/save",
json={
"user_content": "How are you?",
"assistant_content": "I'm doing well, thank you for asking!",
}
)
second_tokens = response.json()["total_tokens"]
assert second_tokens > first_tokens
# Verify via get endpoint
response = await auth_client.get(f"/conversations/{conv_id}")
assert response.status_code == 200
assert response.json()["total_tokens"] == second_tokens
assert len(response.json()["messages"]) == 4
+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
+255 -3
View File
@@ -2,20 +2,37 @@
Webber API client.
Communicates with the Webber API backend for agent execution.
Supports permission modes for controlling agent tool access.
"""
import json
import httpx
from collections.abc import AsyncIterator
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
class PermissionMode(str, Enum):
"""
Permission modes controlling agent tool access.
- default: All tools available (approval may be required)
- plan: Read-only tools only
- auto_accept: All tools, no approval prompts
"""
default = "default"
plan = "plan"
auto_accept = "auto_accept"
@dataclass
class AgentResponse:
"""Response from agent execution."""
response: str
agent_type: str
success: bool
mode: PermissionMode = PermissionMode.default
error: str | None = None
@@ -26,6 +43,47 @@ class AgentInfo:
description: str
@dataclass
class Message:
"""A message in a conversation."""
id: str
role: str
content: str
token_count: int
is_summary: bool
created_at: datetime
@dataclass
class Conversation:
"""A conversation session."""
id: str
agent_type: str
title: str | None
working_dir: str
total_tokens: int
created_at: datetime
updated_at: datetime | None
messages: list[Message] = field(default_factory=list)
@dataclass
class AddMessageResult:
"""Result of adding a message to a conversation."""
user_message: Message
assistant_message: Message
total_tokens: int
summarized: bool
@dataclass
class SaveMessagesResult:
"""Result of saving a message pair without agent execution."""
user_message: Message
assistant_message: Message
total_tokens: int
class WebberClient:
"""
Client for the Webber API.
@@ -104,14 +162,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 +183,7 @@ class WebberClient:
"agent_type": agent_type,
"prompt": prompt,
"working_dir": working_dir,
"mode": mode.value,
},
)
response.raise_for_status()
@@ -131,6 +192,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 +201,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 +227,7 @@ class WebberClient:
"agent_type": agent_type,
"prompt": prompt,
"working_dir": working_dir,
"mode": mode.value,
},
) as response:
response.raise_for_status()
@@ -180,6 +245,193 @@ class WebberClient:
except json.JSONDecodeError:
continue
# === Conversation API ===
def _parse_message(self, data: dict) -> Message:
"""Parse a Message from API response data."""
return Message(
id=data["id"],
role=data["role"],
content=data["content"],
token_count=data["token_count"],
is_summary=data["is_summary"],
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
)
def _parse_conversation(self, data: dict, with_messages: bool = False) -> Conversation:
"""Parse a Conversation from API response data."""
messages = []
if with_messages and "messages" in data:
messages = [self._parse_message(m) for m in data["messages"]]
updated_at = None
if data.get("updated_at"):
updated_at = datetime.fromisoformat(data["updated_at"].replace("Z", "+00:00"))
return Conversation(
id=data["id"],
agent_type=data["agent_type"],
title=data.get("title"),
working_dir=data["working_dir"],
total_tokens=data["total_tokens"],
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
updated_at=updated_at,
messages=messages,
)
async def list_conversations(
self,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Conversation], int]:
"""
List user's conversations.
Args:
limit: Maximum number of conversations to return
offset: Offset for pagination
Returns:
Tuple of (conversations, total_count)
"""
client = await self._get_client()
response = await client.get(
"/conversations/",
params={"limit": limit, "offset": offset},
)
response.raise_for_status()
data = response.json()
conversations = [self._parse_conversation(c) for c in data["conversations"]]
return conversations, data["total"]
async def get_conversation(self, conversation_id: str) -> Conversation | None:
"""
Get a conversation with all messages.
Args:
conversation_id: UUID of the conversation
Returns:
Conversation with messages, or None if not found
"""
client = await self._get_client()
response = await client.get(f"/conversations/{conversation_id}")
if response.status_code == 404:
return None
response.raise_for_status()
return self._parse_conversation(response.json(), with_messages=True)
async def create_conversation(
self,
agent_type: str = "task",
working_dir: str = ".",
title: str | None = None,
) -> Conversation:
"""
Create a new conversation.
Args:
agent_type: Type of agent to use
working_dir: Working directory for the agent
title: Optional title for the conversation
Returns:
The created conversation
"""
client = await self._get_client()
response = await client.post(
"/conversations/",
json={
"agent_type": agent_type,
"working_dir": working_dir,
"title": title,
},
)
response.raise_for_status()
return self._parse_conversation(response.json())
async def add_message(
self,
conversation_id: str,
content: str,
) -> AddMessageResult:
"""
Add a message to a conversation and get agent response.
Args:
conversation_id: UUID of the conversation
content: Message content
Returns:
AddMessageResult with user and assistant messages
"""
client = await self._get_client()
response = await client.post(
f"/conversations/{conversation_id}/messages",
json={"content": content},
)
response.raise_for_status()
data = response.json()
return AddMessageResult(
user_message=self._parse_message(data["user_message"]),
assistant_message=self._parse_message(data["assistant_message"]),
total_tokens=data["total_tokens"],
summarized=data.get("summarized", False),
)
async def save_messages(
self,
conversation_id: str,
user_content: str,
assistant_content: str,
) -> SaveMessagesResult:
"""
Save a user/assistant message pair without triggering agent execution.
Used when streaming responses separately via run_agent_stream().
Allows persisting the exchange after streaming completes.
Args:
conversation_id: UUID of the conversation
user_content: User message content
assistant_content: Assistant response content
Returns:
SaveMessagesResult with both messages
"""
client = await self._get_client()
response = await client.post(
f"/conversations/{conversation_id}/save",
json={
"user_content": user_content,
"assistant_content": assistant_content,
},
)
response.raise_for_status()
data = response.json()
return SaveMessagesResult(
user_message=self._parse_message(data["user_message"]),
assistant_message=self._parse_message(data["assistant_message"]),
total_tokens=data["total_tokens"],
)
async def delete_conversation(self, conversation_id: str) -> bool:
"""
Delete a conversation.
Args:
conversation_id: UUID of the conversation
Returns:
True if deleted, False if not found
"""
client = await self._get_client()
response = await client.delete(f"/conversations/{conversation_id}")
if response.status_code == 404:
return False
response.raise_for_status()
return True
async def __aenter__(self) -> "WebberClient":
"""Async context manager entry."""
return self
+433 -112
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",
@@ -33,15 +107,35 @@ console = get_console()
# Development port is 8095, production is 8086
DEFAULT_API_URL = os.environ.get("WEBBER_API_URL", "http://localhost:8095")
# Default API key for conversation API (dev mode accepts any non-empty key)
DEFAULT_API_KEY = os.environ.get("WEBBER_API_KEY", "webber-cli-dev-key")
def version_callback(value: bool) -> None:
"""Display version and exit."""
if value:
from cli import __version__
from webber_cli import __version__
console.print(f"[title]webber-cli[/] version [success]{__version__}[/]")
raise typer.Exit()
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(
@@ -57,13 +151,76 @@ def main(
pass
@app.command()
def sessions(
api_url: str = typer.Option(
DEFAULT_API_URL,
"--api",
"-a",
help="Webber API URL",
),
limit: int = typer.Option(
20,
"--limit",
"-n",
help="Maximum number of sessions to show",
),
) -> None:
"""
List previous conversation sessions.
Shows recent sessions that can be resumed with 'chat --resume <id>'.
"""
asyncio.run(_list_sessions(api_url, limit))
async def _list_sessions(api_url: str, limit: int) -> None:
"""List conversation sessions."""
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
# Check API health
if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
return
try:
conversations, total = await client.list_conversations(limit=limit)
except Exception as e:
console.print(f"[error]Error listing sessions:[/] {e}")
return
if not conversations:
console.print("[dim]No sessions found. Start one with 'webber-cli chat'[/]")
return
console.print(f"[title]Sessions[/] [dim]({len(conversations)} of {total})[/]\n")
for conv in conversations:
# Format the date
date_str = conv.created_at.strftime("%Y-%m-%d %H:%M")
# Title or first message preview
title = conv.title or "[dim]untitled[/]"
# Truncate ID for display
short_id = conv.id[:8]
console.print(
f" [info]{short_id}[/] {date_str} "
f"[path]{conv.working_dir}[/] {title} "
f"[dim]({conv.total_tokens} tokens)[/]"
)
console.print()
console.print("[dim]Resume with: webber-cli chat --resume <id>[/]")
@app.command()
def chat(
directory: str = typer.Option(
".",
"--directory",
"-d",
help="Working directory for exploration",
help="Working directory for the agent",
),
api_url: str = typer.Option(
DEFAULT_API_URL,
@@ -71,10 +228,17 @@ 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)",
),
resume: str = typer.Option(
None,
"--resume",
"-r",
help="Resume a previous session by ID (use 'sessions' to list)",
),
stream: bool = typer.Option(
True,
@@ -84,9 +248,20 @@ 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)
Use --resume to continue a previous session.
"""
working_dir = str(Path(directory).resolve())
@@ -94,19 +269,40 @@ 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, resume))
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/]")
async def _chat_loop(
api_url: str, agent_type: str, working_dir: str, stream: bool = True
api_url: str,
working_dir: str,
mode: PermissionMode,
stream: bool = True,
resume_id: str | None = None,
) -> None:
"""Interactive chat loop."""
"""Interactive chat loop with the Task agent."""
theme = get_theme()
agent_type = "task"
conversation_id: str | None = None
conversation_title: str | None = None
async with WebberClient(api_url) as client:
async with WebberClient(api_url, api_key=DEFAULT_API_KEY) as client:
# Check API health
if not await client.health_check():
console.print(f"[error]Error:[/] Cannot connect to Webber API at {api_url}")
@@ -116,28 +312,99 @@ async def _chat_loop(
# Get agent info
agent_info = await client.get_agent(agent_type)
if not agent_info:
console.print(f"[error]Error:[/] Unknown agent: {agent_type}")
agents = await client.list_agents()
console.print("[dim]Available agents:[/]")
for a in agents:
console.print(f" - {a.name}: {a.description}")
console.print(f"[error]Error:[/] Task agent not found")
return
# Handle resume or create new conversation
if resume_id:
# Try to find conversation by ID prefix
try:
conversations, _ = await client.list_conversations(limit=100)
matching = [c for c in conversations if c.id.startswith(resume_id)]
if not matching:
console.print(f"[error]Error:[/] Session not found: {resume_id}")
console.print("[dim]Use 'webber-cli sessions' to list available sessions[/]")
return
if len(matching) > 1:
console.print(f"[error]Error:[/] Ambiguous ID, multiple matches: {resume_id}")
for m in matching:
console.print(f" - {m.id[:8]} ({m.title or 'untitled'})")
return
# Load the conversation with messages
conv = await client.get_conversation(matching[0].id)
if not conv:
console.print(f"[error]Error:[/] Could not load session")
return
conversation_id = conv.id
conversation_title = conv.title
working_dir = conv.working_dir # Use the session's working directory
# Display conversation history
console.print(f"\n[title]Resuming session[/] [dim]{conv.id[:8]}[/]")
if conv.messages:
console.print(f"[dim]({len(conv.messages)} messages, {conv.total_tokens} tokens)[/]\n")
for msg in conv.messages[-6:]: # Show last 6 messages
if msg.role == "user":
console.print(f"[prompt]>[/] {msg.content[:100]}{'...' if len(msg.content) > 100 else ''}")
else:
preview = msg.content[:200].replace('\n', ' ')
console.print(f"[dim]{preview}{'...' if len(msg.content) > 200 else ''}[/]\n")
except Exception as e:
console.print(f"[error]Error resuming session:[/] {e}")
return
else:
# Create a new conversation
try:
conv = await client.create_conversation(
agent_type=agent_type,
working_dir=working_dir,
title=None, # Will be set later based on first message
)
conversation_id = conv.id
console.print(f"[dim]Session: {conv.id[:8]}[/]")
except Exception as e:
# If conversation API fails, continue without persistence
console.print(f"[dim]Note: Session persistence unavailable ({e})[/]")
# Mode display
mode_display = {
PermissionMode.default: "[info]default[/] (full with approvals)",
PermissionMode.plan: "[success]plan[/] (read-only)",
PermissionMode.auto_accept: "[warning]auto_accept[/] (no prompts)",
}
# Welcome message
console.print()
console.print(f"[title]Webber CLI[/] [dim]→ {api_url}[/]")
console.print(f"[dim]Working in:[/] [path]{working_dir}[/]")
console.print(f"[dim]Agent:[/] {agent_info.name} - {agent_info.description}")
mode = "streaming" if stream else "batch"
console.print(f"[dim]Mode:[/] {mode}")
console.print(f"[dim]Mode:[/] {mode_display[mode]}")
console.print(f"[dim]Streaming:[/] {'enabled' if stream else 'disabled'}")
console.print()
console.print("[dim]Type 'exit' to quit, 'clear' to clear screen.[/]")
console.print("[dim]Commands: 'exit' to quit, 'clear' to clear, 'mode <plan|default|auto_accept>' to switch[/]")
console.print("[dim]Tab for completion, Up/Down for history[/]")
console.print()
current_mode = mode
# Create prompt session with history and completion
session = create_prompt_session(working_dir)
# Chat loop
while True:
try:
user_input = console.input("[prompt]>[/] ").strip()
# Use prompt_toolkit for input (with history and completion)
try:
user_input = await session.prompt_async(
[("class:prompt", "> ")],
)
user_input = user_input.strip()
except EOFError:
# Ctrl+D pressed
console.print("[dim]Goodbye![/]")
break
if not user_input:
continue
@@ -152,36 +419,82 @@ async def _chat_loop(
if user_input.lower().startswith("cd "):
new_dir = user_input[3:].strip()
new_path = Path(new_dir).resolve()
# Handle ~ expansion
new_path = Path(new_dir).expanduser().resolve()
if new_path.exists() and new_path.is_dir():
working_dir = str(new_path)
# Update completer's working directory
session.completer.working_dir = working_dir
console.print(f"[info]Changed to:[/] [path]{working_dir}[/]")
else:
console.print(f"[error]Directory not found:[/] {new_dir}")
continue
# Mode switching
if user_input.lower().startswith("mode "):
new_mode_str = user_input[5:].strip()
try:
new_mode = PermissionMode(new_mode_str)
if new_mode == PermissionMode.auto_accept:
if not _confirm_auto_accept():
console.print("[dim]Mode unchanged.[/]")
continue
current_mode = new_mode
console.print(f"[info]Mode changed to:[/] {mode_display[current_mode]}")
except ValueError:
console.print(f"[error]Invalid mode:[/] {new_mode_str}")
console.print("[dim]Valid modes: default, plan, auto_accept[/]")
continue
console.print()
if stream:
# Stream response in real-time
full_response = ""
# Stream response in real-time, buffering for persistence
response_chunks: list[str] = []
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
response_chunks.append(chunk)
console.print() # Newline after streaming
# Save messages to conversation if we have a session
if conversation_id and response_chunks:
try:
full_response = "".join(response_chunks)
await client.save_messages(
conversation_id,
user_input,
full_response,
)
except Exception as save_error:
# Log but don't fail the interaction
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
except Exception as e:
console.print(f"\n[error]Stream error:[/] {e}")
else:
# Batch mode with spinner
with console.status("[info]Thinking...[/]", spinner=theme.spinner):
result = await client.run_agent(agent_type, user_input, working_dir)
result = await client.run_agent(
agent_type, user_input, working_dir, current_mode
)
if result.success:
console.print(Markdown(result.response))
# Save messages to conversation if we have a session
if conversation_id:
try:
await client.save_messages(
conversation_id,
user_input,
result.response,
)
except Exception as save_error:
console.print(f"[dim]Note: Could not save to session ({save_error})[/]")
else:
console.print(f"[error]Error:[/] {result.error}")
@@ -194,87 +507,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 +536,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()