Source code for atloop.tools.base

"""Base classes and types for tools."""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, Optional

from atloop.tools.output_semantic_type import OutputSemanticType


[docs] @dataclass class ToolResult: """Result of tool execution.""" ok: bool stdout: str stderr: str meta: Dict[str, Any]
[docs] def __repr__(self) -> str: """String representation.""" status = "✓" if self.ok else "✗" return f"ToolResult({status}, stdout_len={len(self.stdout)}, stderr_len={len(self.stderr)})"
[docs] class BaseTool(ABC): """Base class for all tools. Tools can declare their output semantic types to enable automatic application of appropriate output size limits. """ @property @abstractmethod def name(self) -> str: """Tool name.""" pass @property @abstractmethod def description(self) -> str: """Tool description.""" pass @property def output_semantic_type(self) -> OutputSemanticType: """Return the semantic type of tool output. Default: STATUS_MESSAGE (suitable for most tools that return simple success/failure messages). Subclasses can override this property to declare their output semantic type. Returns: OutputSemanticType enum value """ return OutputSemanticType.STATUS_MESSAGE @property def stdout_semantic_type(self) -> OutputSemanticType: """Return the semantic type of stdout output. Default: Uses output_semantic_type. Subclasses can override this if stdout has a different semantic type than the general output. Returns: OutputSemanticType enum value """ return self.output_semantic_type @property def stderr_semantic_type(self) -> OutputSemanticType: """Return the semantic type of stderr output. Default: ERROR_MESSAGE (stderr is typically error information). Subclasses can override this if stderr has a different semantic type. Returns: OutputSemanticType enum value """ return OutputSemanticType.ERROR_MESSAGE
[docs] @abstractmethod def execute(self, args: Dict[str, Any]) -> ToolResult: """Execute tool with given arguments.""" pass
[docs] def needs_permission(self, args: Dict[str, Any]) -> bool: """Whether this tool needs user permission.""" return False
[docs] def validate_args(self, args: Dict[str, Any]) -> tuple[bool, Optional[str]]: """Validate tool arguments. Returns (is_valid, error_message).""" return True, None
[docs] def get_detailed_description(self) -> str: """ Generate detailed tool description for LLM prompts. Extracts comprehensive information from the tool's docstring including: - Main description - Usage guidelines - Important warnings - Parameters - Examples - When to use vs other tools Returns: Formatted detailed description string suitable for LLM prompts """ import inspect import re # Get class docstring docstring = inspect.getdoc(self.__class__) or "" if not docstring: # Fallback to simple description return f"{self.description}\n\n*No detailed documentation available.*" # Extract main description (first paragraph before any ** markers or empty line) lines = docstring.split('\n') main_desc_parts = [] for line in lines: line_stripped = line.strip() # Stop at first ** marker (section start) or after first paragraph if line_stripped.startswith('**'): break if line_stripped: main_desc_parts.append(line_stripped) elif main_desc_parts: # Empty line after content - end of first paragraph break main_desc = ' '.join(main_desc_parts).strip() if not main_desc: main_desc = self.description # Extract key sections from docstring sections = {} # Extract **Important**, **Use cases**, **When to use**, etc. current_section = None current_content = [] for line in lines: line_stripped = line.strip() # Check for section headers (lines starting with **) # Match patterns like **CRITICAL**, **⚠️ CRITICAL**:, **Use cases:**, etc. # Note: Some headers end with : instead of ** if line_stripped.startswith('**'): # Save previous section if current_section and current_content: sections[current_section] = '\n'.join(current_content).strip() # Extract section name - remove ** markers from start and end # Handle patterns like **CRITICAL**, **⚠️ CRITICAL**:, **Use cases:** # Also handle cases where content is on the same line: **⚠️ CRITICAL**: content section_line = line_stripped # Remove leading ** if section_line.startswith('**'): section_line = section_line[2:] # Find where the section name ends (either **, :, or end of line) # Look for : followed by content, or ** at end section_name = section_line content_after_colon = None # Check if there's content after a colon (same-line content) if ':' in section_line: colon_pos = section_line.find(':') # Check if this looks like a section header with content # Pattern: **Name**: content potential_name = section_line[:colon_pos].strip() potential_content = section_line[colon_pos+1:].strip() # If there's substantial content after colon, it's same-line content if potential_content and len(potential_content) > 3: section_name = potential_name content_after_colon = potential_content else: # Just a trailing colon, remove it section_name = section_line.rstrip(':').strip() elif section_line.endswith('**'): section_name = section_line[:-2].strip() else: section_name = section_line.strip() # Normalize section names (remove emoji, keep key words) # Check for CRITICAL first (most specific) if 'CRITICAL' in section_name.upper(): current_section = 'CRITICAL' elif '⚠️' in section_name and ('Important' in section_name or 'WARNING' in section_name): current_section = 'Important' elif 'Important' in section_name: current_section = 'Important' elif 'WARNING' in section_name: current_section = 'WARNING' else: current_section = section_name # If there was content on the same line, add it immediately if content_after_colon: current_content = [content_after_colon] else: current_content = [] elif current_section: # Continue collecting content for current section # Include empty lines if we already have content (preserves formatting) if line_stripped or (current_content and not line_stripped): current_content.append(line) # Save last section if current_section and current_content: sections[current_section] = '\n'.join(current_content).strip() # Build detailed description parts = [main_desc] # Add important warnings/notes first (highest priority) # Check normalized section names warning_keys = ['CRITICAL', 'Important', 'WARNING'] for key in warning_keys: if key in sections: warning_text = sections[key] # Clean up markdown formatting but preserve content warning_text = re.sub(r'\*\*', '', warning_text) # Remove bold markers parts.append(f"\n⚠️ **Important**: {warning_text}") break # Only show first warning # Add usage guidelines usage_keys = ['Use cases', 'When to use', 'Usage', 'Why use this tool', 'Benefits'] for key in usage_keys: if key in sections: parts.append(f"\n**{key}**:\n{sections[key]}") break # Only show first usage section # Add differences from other tools diff_keys = ['Key differences', 'Why use this instead', 'vs', 'When to use vs'] for key in diff_keys: if key in sections: parts.append(f"\n**{key}**:\n{sections[key]}") break # Add parameter information from execute() method docstring try: execute_doc = inspect.getdoc(self.execute) if execute_doc: # Extract Args section with better regex args_pattern = r'\*\*Args?:\*\*\s*\n(.*?)(?=\n\s*\*\*(?:Returns?|Examples?|Note|Error|WARNING|⚠️)|\Z)' args_match = re.search(args_pattern, execute_doc, re.DOTALL | re.IGNORECASE) if args_match: args_text = args_match.group(1).strip() # Clean up: normalize whitespace but preserve structure args_text = re.sub(r'[ \t]+', ' ', args_text) # Normalize spaces args_text = re.sub(r'\n\s*\n', '\n\n', args_text) # Normalize blank lines if args_text: parts.append(f"\n**Parameters**:\n{args_text}") # Extract Examples section examples_pattern = r'\*\*Examples?:\*\*\s*\n(.*?)(?=\n\s*\*\*(?:Note|Error|WARNING|⚠️)|\Z)' examples_match = re.search(examples_pattern, execute_doc, re.DOTALL | re.IGNORECASE) if examples_match: examples_text = examples_match.group(1).strip() if examples_text: parts.append(f"\n**Examples**:\n{examples_text}") except Exception: # If extraction fails, continue without parameters/examples pass result = '\n'.join(parts) # If result is too short, add simple description # Handle case where description might be a property/method try: desc_str = self.description if isinstance(self.description, str) else str(self.description) if len(result) < len(desc_str) + 50: result = f"{desc_str}\n\n{result}" except Exception: # If description access fails, just return what we have pass return result