Structure webber into three independent subprojects: - webber-api/: FastAPI backend server with all agent code - webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/) - webber-sandbox/: Test project for functional testing Key changes: - Each subproject has its own .venv (Python 3.12+) - Added sandbox.sh for managing test project templates - Created sandbox-templates/ with calculator-cli and empty starter - Updated CI/CD for prefixed tags (api/v*, cli/v*) - Added comprehensive AGENTS.md with operational instructions - Added gitignore filtering to glob and grep tools - Created pyproject.toml for each subproject Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
135 lines
3.4 KiB
Python
135 lines
3.4 KiB
Python
"""
|
|
Base classes for tool implementations.
|
|
|
|
All tools inherit from BaseTool and return ToolResult for consistent handling.
|
|
"""
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class ToolResult:
|
|
"""
|
|
Standardized result from tool execution.
|
|
|
|
All tools return this for consistent error handling and LLM consumption.
|
|
"""
|
|
success: bool
|
|
data: Any
|
|
error: str | None = None
|
|
truncated: bool = False
|
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def to_string(self, max_length: int = 30000) -> str:
|
|
"""
|
|
Convert result to string for LLM consumption.
|
|
|
|
Args:
|
|
max_length: Maximum string length before truncation
|
|
"""
|
|
if not self.success:
|
|
return f"ERROR: {self.error}"
|
|
|
|
if isinstance(self.data, str):
|
|
content = self.data
|
|
else:
|
|
content = str(self.data)
|
|
|
|
if len(content) > max_length:
|
|
self.truncated = True
|
|
content = content[:max_length] + "\n... (truncated)"
|
|
|
|
if self.truncated:
|
|
content += "\n[Output was truncated]"
|
|
|
|
return content
|
|
|
|
def __str__(self) -> str:
|
|
return self.to_string()
|
|
|
|
|
|
class BaseTool(ABC):
|
|
"""
|
|
Abstract base class for all tools.
|
|
|
|
All domain tools (file, shell, search) inherit from this and implement execute().
|
|
|
|
Usage:
|
|
class MyTool(BaseTool):
|
|
name = "my_tool"
|
|
description = "Does something useful"
|
|
|
|
async def execute(self, **kwargs) -> ToolResult:
|
|
return ToolResult(success=True, data="result")
|
|
"""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str:
|
|
"""Tool name for registration and identification."""
|
|
pass
|
|
|
|
@property
|
|
@abstractmethod
|
|
def description(self) -> str:
|
|
"""
|
|
Tool description for LLM.
|
|
|
|
Should include:
|
|
- What the tool does
|
|
- Arguments and their types
|
|
- Return value description
|
|
- Usage constraints/examples
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def execute(self, **kwargs: Any) -> ToolResult:
|
|
"""
|
|
Execute the tool with given arguments.
|
|
|
|
Returns:
|
|
ToolResult with success status and data or error
|
|
"""
|
|
pass
|
|
|
|
def _validate_path(self, path: str | Path, allowed_paths: list[str]) -> bool:
|
|
"""
|
|
Validate that a path is within allowed directories.
|
|
|
|
Args:
|
|
path: Path to validate
|
|
allowed_paths: List of allowed directory prefixes
|
|
|
|
Returns:
|
|
True if path is allowed, False otherwise
|
|
"""
|
|
if not allowed_paths:
|
|
return True # No restrictions when allowed_paths is empty
|
|
|
|
resolved = Path(path).resolve()
|
|
return any(
|
|
str(resolved).startswith(str(Path(allowed).resolve()))
|
|
for allowed in allowed_paths
|
|
)
|
|
|
|
def _error(self, message: str) -> ToolResult:
|
|
"""Create an error result."""
|
|
return ToolResult(success=False, data=None, error=message)
|
|
|
|
def _success(
|
|
self,
|
|
data: Any,
|
|
truncated: bool = False,
|
|
**metadata: Any
|
|
) -> ToolResult:
|
|
"""Create a success result."""
|
|
return ToolResult(
|
|
success=True,
|
|
data=data,
|
|
truncated=truncated,
|
|
metadata=metadata
|
|
)
|