mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 05:27:59 +08:00
feat: personal ai agent framework
This commit is contained in:
101
agent/tools/__init__.py
Normal file
101
agent/tools/__init__.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Import base tool
|
||||
from agent.tools.base_tool import BaseTool
|
||||
from agent.tools.tool_manager import ToolManager
|
||||
|
||||
# Import basic tools (no external dependencies)
|
||||
from agent.tools.calculator.calculator import Calculator
|
||||
from agent.tools.current_time.current_time import CurrentTime
|
||||
|
||||
# Import file operation tools
|
||||
from agent.tools.read.read import Read
|
||||
from agent.tools.write.write import Write
|
||||
from agent.tools.edit.edit import Edit
|
||||
from agent.tools.bash.bash import Bash
|
||||
from agent.tools.grep.grep import Grep
|
||||
from agent.tools.find.find import Find
|
||||
from agent.tools.ls.ls import Ls
|
||||
|
||||
# Import memory tools
|
||||
from agent.tools.memory.memory_search import MemorySearchTool
|
||||
from agent.tools.memory.memory_get import MemoryGetTool
|
||||
|
||||
# Import tools with optional dependencies
|
||||
def _import_optional_tools():
|
||||
"""Import tools that have optional dependencies"""
|
||||
tools = {}
|
||||
|
||||
# Google Search (requires requests)
|
||||
try:
|
||||
from agent.tools.google_search.google_search import GoogleSearch
|
||||
tools['GoogleSearch'] = GoogleSearch
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# File Save (may have dependencies)
|
||||
try:
|
||||
from agent.tools.file_save.file_save import FileSave
|
||||
tools['FileSave'] = FileSave
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Terminal (basic, should work)
|
||||
try:
|
||||
from agent.tools.terminal.terminal import Terminal
|
||||
tools['Terminal'] = Terminal
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return tools
|
||||
|
||||
# Load optional tools
|
||||
_optional_tools = _import_optional_tools()
|
||||
GoogleSearch = _optional_tools.get('GoogleSearch')
|
||||
FileSave = _optional_tools.get('FileSave')
|
||||
Terminal = _optional_tools.get('Terminal')
|
||||
|
||||
|
||||
# Delayed import for BrowserTool
|
||||
def _import_browser_tool():
|
||||
try:
|
||||
from agent.tools.browser.browser_tool import BrowserTool
|
||||
return BrowserTool
|
||||
except ImportError:
|
||||
# Return a placeholder class that will prompt the user to install dependencies when instantiated
|
||||
class BrowserToolPlaceholder:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError(
|
||||
"The 'browser-use' package is required to use BrowserTool. "
|
||||
"Please install it with 'pip install browser-use>=0.1.40'."
|
||||
)
|
||||
|
||||
return BrowserToolPlaceholder
|
||||
|
||||
|
||||
# Dynamically set BrowserTool
|
||||
BrowserTool = _import_browser_tool()
|
||||
|
||||
# Export all tools (including optional ones that might be None)
|
||||
__all__ = [
|
||||
'BaseTool',
|
||||
'ToolManager',
|
||||
'Calculator',
|
||||
'CurrentTime',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Bash',
|
||||
'Grep',
|
||||
'Find',
|
||||
'Ls',
|
||||
'MemorySearchTool',
|
||||
'MemoryGetTool',
|
||||
# Optional tools (may be None if dependencies not available)
|
||||
'GoogleSearch',
|
||||
'FileSave',
|
||||
'Terminal',
|
||||
'BrowserTool'
|
||||
]
|
||||
|
||||
"""
|
||||
Tools module for Agent.
|
||||
"""
|
||||
99
agent/tools/base_tool.py
Normal file
99
agent/tools/base_tool.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from common.log import logger
|
||||
import copy
|
||||
|
||||
|
||||
class ToolStage(Enum):
|
||||
"""Enum representing tool decision stages"""
|
||||
PRE_PROCESS = "pre_process" # Tools that need to be actively selected by the agent
|
||||
POST_PROCESS = "post_process" # Tools that automatically execute after final_answer
|
||||
|
||||
|
||||
class ToolResult:
|
||||
"""Tool execution result"""
|
||||
|
||||
def __init__(self, status: str = None, result: Any = None, ext_data: Any = None):
|
||||
self.status = status
|
||||
self.result = result
|
||||
self.ext_data = ext_data
|
||||
|
||||
@staticmethod
|
||||
def success(result, ext_data: Any = None):
|
||||
return ToolResult(status="success", result=result, ext_data=ext_data)
|
||||
|
||||
@staticmethod
|
||||
def fail(result, ext_data: Any = None):
|
||||
return ToolResult(status="error", result=result, ext_data=ext_data)
|
||||
|
||||
|
||||
class BaseTool:
|
||||
"""Base class for all tools."""
|
||||
|
||||
# Default decision stage is pre-process
|
||||
stage = ToolStage.PRE_PROCESS
|
||||
|
||||
# Class attributes must be inherited
|
||||
name: str = "base_tool"
|
||||
description: str = "Base tool"
|
||||
params: dict = {} # Store JSON Schema
|
||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
||||
|
||||
@classmethod
|
||||
def get_json_schema(cls) -> dict:
|
||||
"""Get the standard description of the tool"""
|
||||
return {
|
||||
"name": cls.name,
|
||||
"description": cls.description,
|
||||
"parameters": cls.params
|
||||
}
|
||||
|
||||
def execute_tool(self, params: dict) -> ToolResult:
|
||||
try:
|
||||
return self.execute(params)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def execute(self, params: dict) -> ToolResult:
|
||||
"""Specific logic to be implemented by subclasses"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def _parse_schema(cls) -> dict:
|
||||
"""Convert JSON Schema to Pydantic fields"""
|
||||
fields = {}
|
||||
for name, prop in cls.params["properties"].items():
|
||||
# Convert JSON Schema types to Python types
|
||||
type_map = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"array": list,
|
||||
"object": dict
|
||||
}
|
||||
fields[name] = (
|
||||
type_map[prop["type"]],
|
||||
prop.get("default", ...)
|
||||
)
|
||||
return fields
|
||||
|
||||
def should_auto_execute(self, context) -> bool:
|
||||
"""
|
||||
Determine if this tool should be automatically executed based on context.
|
||||
|
||||
:param context: The agent context
|
||||
:return: True if the tool should be executed, False otherwise
|
||||
"""
|
||||
# Only tools in post-process stage will be automatically executed
|
||||
return self.stage == ToolStage.POST_PROCESS
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close any resources used by the tool.
|
||||
This method should be overridden by tools that need to clean up resources
|
||||
such as browser connections, file handles, etc.
|
||||
|
||||
By default, this method does nothing.
|
||||
"""
|
||||
pass
|
||||
3
agent/tools/bash/__init__.py
Normal file
3
agent/tools/bash/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .bash import Bash
|
||||
|
||||
__all__ = ['Bash']
|
||||
187
agent/tools/bash/bash.py
Normal file
187
agent/tools/bash/bash.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Bash tool - Execute bash commands
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Dict, Any
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import truncate_tail, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
class Bash(BaseTool):
|
||||
"""Tool for executing bash commands"""
|
||||
|
||||
name: str = "bash"
|
||||
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
||||
|
||||
IMPORTANT SAFETY GUIDELINES:
|
||||
- You can freely create, modify, and delete files within the current workspace
|
||||
- For operations outside the workspace or potentially destructive commands (rm -rf, system commands, etc.), always explain what you're about to do and ask for user confirmation first
|
||||
- Be especially careful with: file deletions, system modifications, network operations, or commands that might affect system stability
|
||||
- When in doubt, describe the command's purpose and ask for permission before executing"""
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Bash command to execute"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (optional, default: 30)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
# Ensure working directory exists
|
||||
if not os.path.exists(self.cwd):
|
||||
os.makedirs(self.cwd, exist_ok=True)
|
||||
self.default_timeout = self.config.get("timeout", 30)
|
||||
# Enable safety mode by default (can be disabled in config)
|
||||
self.safety_mode = self.config.get("safety_mode", True)
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute a bash command
|
||||
|
||||
:param args: Dictionary containing the command and optional timeout
|
||||
:return: Command output or error
|
||||
"""
|
||||
command = args.get("command", "").strip()
|
||||
timeout = args.get("timeout", self.default_timeout)
|
||||
|
||||
if not command:
|
||||
return ToolResult.fail("Error: command parameter is required")
|
||||
|
||||
# Optional safety check - only warn about extremely dangerous commands
|
||||
if self.safety_mode:
|
||||
warning = self._get_safety_warning(command)
|
||||
if warning:
|
||||
return ToolResult.fail(
|
||||
f"Safety Warning: {warning}\n\nIf you believe this command is safe and necessary, please ask the user for confirmation first, explaining what the command does and why it's needed.")
|
||||
|
||||
try:
|
||||
# Execute command
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# Combine stdout and stderr
|
||||
output = result.stdout
|
||||
if result.stderr:
|
||||
output += "\n" + result.stderr
|
||||
|
||||
# Check if we need to save full output to temp file
|
||||
temp_file_path = None
|
||||
total_bytes = len(output.encode('utf-8'))
|
||||
|
||||
if total_bytes > DEFAULT_MAX_BYTES:
|
||||
# Save full output to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
|
||||
f.write(output)
|
||||
temp_file_path = f.name
|
||||
|
||||
# Apply tail truncation
|
||||
truncation = truncate_tail(output)
|
||||
output_text = truncation.content or "(no output)"
|
||||
|
||||
# Build result
|
||||
details = {}
|
||||
|
||||
if truncation.truncated:
|
||||
details["truncation"] = truncation.to_dict()
|
||||
if temp_file_path:
|
||||
details["full_output_path"] = temp_file_path
|
||||
|
||||
# Build notice
|
||||
start_line = truncation.total_lines - truncation.output_lines + 1
|
||||
end_line = truncation.total_lines
|
||||
|
||||
if truncation.last_line_partial:
|
||||
# Edge case: last line alone > 30KB
|
||||
last_line = output.split('\n')[-1] if output else ""
|
||||
last_line_size = format_size(len(last_line.encode('utf-8')))
|
||||
output_text += f"\n\n[Showing last {format_size(truncation.output_bytes)} of line {end_line} (line is {last_line_size}). Full output: {temp_file_path}]"
|
||||
elif truncation.truncated_by == "lines":
|
||||
output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines}. Full output: {temp_file_path}]"
|
||||
else:
|
||||
output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Full output: {temp_file_path}]"
|
||||
|
||||
# Check exit code
|
||||
if result.returncode != 0:
|
||||
output_text += f"\n\nCommand exited with code {result.returncode}"
|
||||
return ToolResult.fail({
|
||||
"output": output_text,
|
||||
"exit_code": result.returncode,
|
||||
"details": details if details else None
|
||||
})
|
||||
|
||||
return ToolResult.success({
|
||||
"output": output_text,
|
||||
"exit_code": result.returncode,
|
||||
"details": details if details else None
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ToolResult.fail(f"Error: Command timed out after {timeout} seconds")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error executing command: {str(e)}")
|
||||
|
||||
def _get_safety_warning(self, command: str) -> str:
|
||||
"""
|
||||
Get safety warning for potentially dangerous commands
|
||||
Only warns about extremely dangerous system-level operations
|
||||
|
||||
:param command: Command to check
|
||||
:return: Warning message if dangerous, empty string if safe
|
||||
"""
|
||||
cmd_lower = command.lower().strip()
|
||||
|
||||
# Only block extremely dangerous system operations
|
||||
dangerous_patterns = [
|
||||
# System shutdown/reboot
|
||||
("shutdown", "This command will shut down the system"),
|
||||
("reboot", "This command will reboot the system"),
|
||||
("halt", "This command will halt the system"),
|
||||
("poweroff", "This command will power off the system"),
|
||||
|
||||
# Critical system modifications
|
||||
("rm -rf /", "This command will delete the entire filesystem"),
|
||||
("rm -rf /*", "This command will delete the entire filesystem"),
|
||||
("dd if=/dev/zero", "This command can destroy disk data"),
|
||||
("mkfs", "This command will format a filesystem, destroying all data"),
|
||||
("fdisk", "This command modifies disk partitions"),
|
||||
|
||||
# User/system management (only if targeting system users)
|
||||
("userdel root", "This command will delete the root user"),
|
||||
("passwd root", "This command will change the root password"),
|
||||
]
|
||||
|
||||
for pattern, warning in dangerous_patterns:
|
||||
if pattern in cmd_lower:
|
||||
return warning
|
||||
|
||||
# Check for recursive deletion outside workspace
|
||||
if "rm" in cmd_lower and "-rf" in cmd_lower:
|
||||
# Allow deletion within current workspace
|
||||
if not any(path in cmd_lower for path in ["./", self.cwd.lower()]):
|
||||
# Check if targeting system directories
|
||||
system_dirs = ["/bin", "/usr", "/etc", "/var", "/home", "/root", "/sys", "/proc"]
|
||||
if any(sysdir in cmd_lower for sysdir in system_dirs):
|
||||
return "This command will recursively delete system directories"
|
||||
|
||||
return "" # No warning needed
|
||||
59
agent/tools/browser/browser_action.py
Normal file
59
agent/tools/browser/browser_action.py
Normal file
@@ -0,0 +1,59 @@
|
||||
class BrowserAction:
|
||||
"""Base class for browser actions"""
|
||||
code = ""
|
||||
description = ""
|
||||
|
||||
|
||||
class Navigate(BrowserAction):
|
||||
"""Navigate to a URL in the current tab"""
|
||||
code = "navigate"
|
||||
description = "Navigate to URL in the current tab"
|
||||
|
||||
|
||||
class ClickElement(BrowserAction):
|
||||
"""Click an element on the page"""
|
||||
code = "click_element"
|
||||
description = "Click element"
|
||||
|
||||
|
||||
class ExtractContent(BrowserAction):
|
||||
"""Extract content from the page"""
|
||||
code = "extract_content"
|
||||
description = "Extract the page content to retrieve specific information for a goal"
|
||||
|
||||
|
||||
class InputText(BrowserAction):
|
||||
"""Input text into an element"""
|
||||
code = "input_text"
|
||||
description = "Input text into a input interactive element"
|
||||
|
||||
|
||||
class ScrollDown(BrowserAction):
|
||||
"""Scroll down the page"""
|
||||
code = "scroll_down"
|
||||
description = "Scroll down the page by pixel amount"
|
||||
|
||||
|
||||
class ScrollUp(BrowserAction):
|
||||
"""Scroll up the page"""
|
||||
code = "scroll_up"
|
||||
description = "Scroll up the page by pixel amount - if no amount is specified, scroll up one page"
|
||||
|
||||
|
||||
class OpenTab(BrowserAction):
|
||||
"""Open a URL in a new tab"""
|
||||
code = "open_tab"
|
||||
description = "Open url in new tab"
|
||||
|
||||
|
||||
class SwitchTab(BrowserAction):
|
||||
"""Switch to a tab"""
|
||||
code = "switch_tab"
|
||||
description = "Switched to tab"
|
||||
|
||||
|
||||
class SendKeys(BrowserAction):
|
||||
"""Switch to a tab"""
|
||||
code = "send_keys"
|
||||
description = "Send strings of special keyboard keys like Escape, Backspace, Insert, PageDown, Delete, Enter, " \
|
||||
"ArrowRight, ArrowUp, etc"
|
||||
317
agent/tools/browser/browser_tool.py
Normal file
317
agent/tools/browser/browser_tool.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import platform
|
||||
from browser_use import Browser
|
||||
from browser_use import BrowserConfig
|
||||
from browser_use.browser.context import BrowserContext, BrowserContextConfig
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.browser.browser_action import *
|
||||
from agent.models import LLMRequest
|
||||
from agent.models.model_factory import ModelFactory
|
||||
from browser_use.dom.service import DomService
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# Use lazy import, only import when actually used
|
||||
def _import_browser_use():
|
||||
try:
|
||||
import browser_use
|
||||
return browser_use
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'browser-use' package is required to use BrowserTool. "
|
||||
"Please install it with 'pip install browser-use>=0.1.40' or "
|
||||
"'pip install agentmesh-sdk[full]'."
|
||||
)
|
||||
|
||||
|
||||
def _get_action_prompt():
|
||||
action_classes = [Navigate, ClickElement, ExtractContent, InputText, OpenTab, SwitchTab, ScrollDown, ScrollUp,
|
||||
SendKeys]
|
||||
action_prompt = ""
|
||||
for action_class in action_classes:
|
||||
action_prompt += f"{action_class.code}: {action_class.description}\n"
|
||||
return action_prompt.strip()
|
||||
|
||||
|
||||
def _header_less() -> bool:
|
||||
if platform.system() == "Linux" and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BrowserTool(BaseTool):
|
||||
name: str = "browser"
|
||||
description: str = "A tool to perform browser operations like navigating to URLs, element interaction, " \
|
||||
"and extracting content."
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": f"The browser operation to perform: \n{_get_action_prompt()}"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": f"The URL to navigate to (required for '{Navigate.code}', '{OpenTab.code}' actions). "
|
||||
},
|
||||
"goal": {
|
||||
"type": "string",
|
||||
"description": f"The goal of extracting page content (required for '{ExtractContent.code}' action)."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": f"Text to type (required for '{InputText.code}' action)."
|
||||
},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"description": f"Element index (required for '{ClickElement.code}', '{InputText.code}' actions)",
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"description": f"Page tab ID (required for '{SwitchTab.code}' action)",
|
||||
},
|
||||
"scroll_amount": {
|
||||
"type": "integer",
|
||||
"description": f"The number of pixels to scroll (required for '{ScrollDown.code}', '{ScrollUp.code}' action)."
|
||||
},
|
||||
"keys": {
|
||||
"type": "string",
|
||||
"description": f"Keys to send (required for '{SendKeys.code}' action)"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
|
||||
# Class variable to ensure only one browser instance is created
|
||||
browser = None
|
||||
browser_context: BrowserContext = None
|
||||
dom_service: DomService = None
|
||||
_initialized = False
|
||||
|
||||
# Adding an event loop variable
|
||||
_event_loop = None
|
||||
|
||||
def __init__(self):
|
||||
# Only import during initialization, not at module level
|
||||
self.browser_use = _import_browser_use()
|
||||
# Do not initialize the browser in the constructor, but initialize it on the first execution
|
||||
pass
|
||||
|
||||
async def _init_browser(self) -> BrowserContext:
|
||||
"""Ensure the browser is initialized"""
|
||||
if not BrowserTool._initialized:
|
||||
os.environ['BROWSER_USE_LOGGING_LEVEL'] = 'error'
|
||||
print("Initializing browser...")
|
||||
# Initialize the browser synchronously
|
||||
BrowserTool.browser = Browser(BrowserConfig(headless=_header_less(),
|
||||
disable_security=True))
|
||||
context_config = BrowserContextConfig()
|
||||
context_config.highlight_elements = True
|
||||
BrowserTool.browser_context = await BrowserTool.browser.new_context(context_config)
|
||||
BrowserTool._initialized = True
|
||||
print("Browser initialized successfully")
|
||||
BrowserTool.dom_service = DomService(await BrowserTool.browser_context.get_current_page())
|
||||
return BrowserTool.browser_context
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute browser operations based on the provided arguments.
|
||||
|
||||
:param params: Dictionary containing the action and related parameters
|
||||
:return: Result of the browser operation
|
||||
"""
|
||||
# Ensure browser_use is imported
|
||||
if not hasattr(self, 'browser_use'):
|
||||
self.browser_use = _import_browser_use()
|
||||
action = params.get("operation", "").lower()
|
||||
|
||||
try:
|
||||
# Use a single event loop
|
||||
if BrowserTool._event_loop is None:
|
||||
BrowserTool._event_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(BrowserTool._event_loop)
|
||||
# Run tasks in the existing event loop
|
||||
return BrowserTool._event_loop.run_until_complete(self._execute_async(action, params))
|
||||
except Exception as e:
|
||||
print(f"Error executing browser action: {e}")
|
||||
return ToolResult.fail(result=f"Error executing browser action: {str(e)}")
|
||||
|
||||
async def _get_page_state(self, context: BrowserContext):
|
||||
state = await self._get_state(context)
|
||||
include_attributes = ["img", "div", "button", "input"]
|
||||
elements = state.element_tree.clickable_elements_to_string(include_attributes)
|
||||
pattern = r'\[\d+\]<[^>]+\/>'
|
||||
# Find all matching elements
|
||||
interactive_elements = re.findall(pattern, elements)
|
||||
page_state = {
|
||||
"url": state.url,
|
||||
"title": state.title,
|
||||
"pixels_above": getattr(state, "pixels_above", 0),
|
||||
"pixels_below": getattr(state, "pixels_below", 0),
|
||||
"tabs": [tab.model_dump() for tab in state.tabs],
|
||||
"interactive_elements": interactive_elements,
|
||||
}
|
||||
return page_state
|
||||
|
||||
async def _get_state(self, context: BrowserContext, cache_clickable_elements_hashes=True):
|
||||
try:
|
||||
return await context.get_state()
|
||||
except TypeError:
|
||||
return await context.get_state(cache_clickable_elements_hashes=cache_clickable_elements_hashes)
|
||||
|
||||
async def _get_page_info(self, context: BrowserContext):
|
||||
page_state = await self._get_page_state(context)
|
||||
state_str = f"""## Current browser state
|
||||
The following is the information of the current browser page. Each serial number in interactive_elements represents the element index:
|
||||
{json.dumps(page_state, indent=4, ensure_ascii=False)}
|
||||
"""
|
||||
return state_str
|
||||
|
||||
async def _execute_async(self, action: str, params: Dict[str, Any]) -> ToolResult:
|
||||
"""Asynchronously execute browser operations"""
|
||||
# Use the browser context from the class variable
|
||||
context = await self._init_browser()
|
||||
|
||||
if action == Navigate.code:
|
||||
url = params.get("url")
|
||||
if not url:
|
||||
return ToolResult.fail(result="URL is required for navigate action")
|
||||
if url.startswith("/"):
|
||||
url = f"file://{url}"
|
||||
print(f"Navigating to {url}...")
|
||||
page = await context.get_current_page()
|
||||
await page.goto(url)
|
||||
await page.wait_for_load_state()
|
||||
state = await self._get_page_info(context)
|
||||
# print(state)
|
||||
print(f"Navigation complete")
|
||||
return ToolResult.success(result=f"Navigated to {url}", ext_data=state)
|
||||
|
||||
elif action == OpenTab.code:
|
||||
url = params.get("url")
|
||||
if url.startswith("/"):
|
||||
url = f"file://{url}"
|
||||
await context.create_new_tab(url)
|
||||
msg = f"Opened new tab with {url}"
|
||||
return ToolResult.success(result=msg)
|
||||
|
||||
elif action == ExtractContent.code:
|
||||
try:
|
||||
goal = params.get("goal")
|
||||
page = await context.get_current_page()
|
||||
if params.get("url"):
|
||||
await page.goto(params.get("url"))
|
||||
await page.wait_for_load_state()
|
||||
import markdownify
|
||||
content = markdownify.markdownify(await page.content())
|
||||
elements = await self._get_page_state(context)
|
||||
prompt = f"Your task is to extract the content of the page. You will be given a page and a goal and you should extract all relevant information around this goal from the page. If the goal is vague, " \
|
||||
f"summarize the page. Respond in json format. elements: {elements.get('interactive_elements')}, extraction goal: {goal}, Page: {content},"
|
||||
request = LLMRequest(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0,
|
||||
json_format=True
|
||||
)
|
||||
model = self.model or ModelFactory().get_model(model_name="gpt-4o")
|
||||
response = model.call(request)
|
||||
if response.success:
|
||||
extract_content = response.data["choices"][0]["message"]["content"]
|
||||
print(f"Extract from page: {extract_content}")
|
||||
return ToolResult.success(result=f"Extract from page: {extract_content}",
|
||||
ext_data=await self._get_page_info(context))
|
||||
else:
|
||||
return ToolResult.fail(result=f"Extract from page failed: {response.get_error_msg()}")
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
elif action == ClickElement.code:
|
||||
index = params.get("index")
|
||||
element = await context.get_dom_element_by_index(index)
|
||||
await context._click_element_node(element)
|
||||
msg = f"Clicked element at index {index}"
|
||||
print(msg)
|
||||
return ToolResult.success(result=msg, ext_data=await self._get_page_info(context))
|
||||
|
||||
elif action == InputText.code:
|
||||
index = params.get("index")
|
||||
text = params.get("text")
|
||||
element = await context.get_dom_element_by_index(index)
|
||||
await context._input_text_element_node(element, text)
|
||||
await asyncio.sleep(1)
|
||||
msg = f"Input text into element successfully, index: {index}, text: {text}"
|
||||
return ToolResult.success(result=msg, ext_data=await self._get_page_info(context))
|
||||
|
||||
elif action == SwitchTab.code:
|
||||
tab_id = params.get("tab_id")
|
||||
print(f"Switch tab, tab_id={tab_id}")
|
||||
await context.switch_to_tab(tab_id)
|
||||
page = await context.get_current_page()
|
||||
await page.wait_for_load_state()
|
||||
msg = f"Switched to tab {tab_id}"
|
||||
return ToolResult.success(result=msg, ext_data=await self._get_page_info(context))
|
||||
|
||||
elif action in [ScrollDown.code, ScrollUp.code]:
|
||||
scroll_amount = params.get("scroll_amount")
|
||||
if not scroll_amount:
|
||||
scroll_amount = context.config.browser_window_size["height"]
|
||||
print(f"Scrolling by {scroll_amount} pixels")
|
||||
scroll_amount = scroll_amount if action == ScrollDown.code else (scroll_amount * -1)
|
||||
await context.execute_javascript(f"window.scrollBy(0, {scroll_amount});")
|
||||
msg = f"{action} by {scroll_amount} pixels"
|
||||
return ToolResult.success(result=msg, ext_data=await self._get_page_info(context))
|
||||
|
||||
elif action == SendKeys.code:
|
||||
keys = params.get("keys")
|
||||
page = await context.get_current_page()
|
||||
await page.keyboard.press(keys)
|
||||
msg = f"Sent keys: {keys}"
|
||||
print(msg)
|
||||
return ToolResult(output=f"Sent keys: {keys}")
|
||||
|
||||
else:
|
||||
msg = "Failed to operate the browser"
|
||||
return ToolResult.fail(result=msg)
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Close browser resources.
|
||||
This method handles the asynchronous closing of browser and browser context.
|
||||
"""
|
||||
if not BrowserTool._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
# Use the existing event loop to close browser resources
|
||||
if BrowserTool._event_loop is not None:
|
||||
# Define the async close function
|
||||
async def close_browser_async():
|
||||
if BrowserTool.browser_context is not None:
|
||||
try:
|
||||
await BrowserTool.browser_context.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing browser context: {e}")
|
||||
|
||||
if BrowserTool.browser is not None:
|
||||
try:
|
||||
await BrowserTool.browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing browser: {e}")
|
||||
|
||||
# Reset the initialized flag
|
||||
BrowserTool._initialized = False
|
||||
BrowserTool.browser = None
|
||||
BrowserTool.browser_context = None
|
||||
BrowserTool.dom_service = None
|
||||
|
||||
# Run the async close function in the existing event loop
|
||||
BrowserTool._event_loop.run_until_complete(close_browser_async())
|
||||
|
||||
# Close the event loop
|
||||
BrowserTool._event_loop.close()
|
||||
BrowserTool._event_loop = None
|
||||
except Exception as e:
|
||||
print(f"Error during browser cleanup: {e}")
|
||||
18
agent/tools/browser_tool.py
Normal file
18
agent/tools/browser_tool.py
Normal file
@@ -0,0 +1,18 @@
|
||||
def copy(self):
|
||||
"""
|
||||
Special copy method for browser tool to avoid recreating browser instance.
|
||||
|
||||
:return: A new instance with shared browser reference but unique model
|
||||
"""
|
||||
new_tool = self.__class__()
|
||||
|
||||
# Copy essential attributes
|
||||
new_tool.model = self.model
|
||||
new_tool.context = getattr(self, 'context', None)
|
||||
new_tool.config = getattr(self, 'config', None)
|
||||
|
||||
# Share the browser instance instead of creating a new one
|
||||
if hasattr(self, 'browser'):
|
||||
new_tool.browser = self.browser
|
||||
|
||||
return new_tool
|
||||
58
agent/tools/calculator/calculator.py
Normal file
58
agent/tools/calculator/calculator.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import math
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class Calculator(BaseTool):
|
||||
name: str = "calculator"
|
||||
description: str = "A tool to perform basic mathematical calculations."
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "The mathematical expression to evaluate (e.g., '2 + 2', '5 * 3', 'sqrt(16)'). "
|
||||
"Ensure your input is a valid Python expression, it will be evaluated directly."
|
||||
}
|
||||
},
|
||||
"required": ["expression"]
|
||||
}
|
||||
config: dict = {}
|
||||
|
||||
def execute(self, args: dict) -> ToolResult:
|
||||
try:
|
||||
# Get the expression
|
||||
expression = args["expression"]
|
||||
|
||||
# Create a safe local environment containing only basic math functions
|
||||
safe_locals = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"pow": pow,
|
||||
"sqrt": math.sqrt,
|
||||
"sin": math.sin,
|
||||
"cos": math.cos,
|
||||
"tan": math.tan,
|
||||
"pi": math.pi,
|
||||
"e": math.e,
|
||||
"log": math.log,
|
||||
"log10": math.log10,
|
||||
"exp": math.exp,
|
||||
"floor": math.floor,
|
||||
"ceil": math.ceil
|
||||
}
|
||||
|
||||
# Safely evaluate the expression
|
||||
result = eval(expression, {"__builtins__": {}}, safe_locals)
|
||||
|
||||
return ToolResult.success({
|
||||
"result": result,
|
||||
"expression": expression
|
||||
})
|
||||
except Exception as e:
|
||||
return ToolResult.success({
|
||||
"error": str(e),
|
||||
"expression": args.get("expression", "")
|
||||
})
|
||||
75
agent/tools/current_time/current_time.py
Normal file
75
agent/tools/current_time/current_time.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class CurrentTime(BaseTool):
|
||||
name: str = "time"
|
||||
description: str = "A tool to get current date and time information."
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "Optional format for the time (e.g., 'iso', 'unix', 'human'). Default is 'human'."
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "Optional timezone specification (e.g., 'UTC', 'local'). Default is 'local'."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
config: dict = {}
|
||||
|
||||
def execute(self, args: dict) -> ToolResult:
|
||||
try:
|
||||
# Get the format and timezone parameters, with defaults
|
||||
time_format = args.get("format", "human").lower()
|
||||
timezone = args.get("timezone", "local").lower()
|
||||
|
||||
# Get current time
|
||||
current_time = datetime.datetime.now()
|
||||
|
||||
# Handle timezone if specified
|
||||
if timezone == "utc":
|
||||
current_time = datetime.datetime.utcnow()
|
||||
|
||||
# Format the time according to the specified format
|
||||
if time_format == "iso":
|
||||
# ISO 8601 format
|
||||
formatted_time = current_time.isoformat()
|
||||
elif time_format == "unix":
|
||||
# Unix timestamp (seconds since epoch)
|
||||
formatted_time = time.time()
|
||||
else:
|
||||
# Human-readable format
|
||||
formatted_time = current_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Prepare additional time components for the response
|
||||
year = current_time.year
|
||||
month = current_time.month
|
||||
day = current_time.day
|
||||
hour = current_time.hour
|
||||
minute = current_time.minute
|
||||
second = current_time.second
|
||||
weekday = current_time.strftime("%A") # Full weekday name
|
||||
|
||||
result = {
|
||||
"current_time": formatted_time,
|
||||
"components": {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"hour": hour,
|
||||
"minute": minute,
|
||||
"second": second,
|
||||
"weekday": weekday
|
||||
},
|
||||
"format": time_format,
|
||||
"timezone": timezone
|
||||
}
|
||||
return ToolResult.success(result=result)
|
||||
except Exception as e:
|
||||
return ToolResult.fail(result=str(e))
|
||||
3
agent/tools/edit/__init__.py
Normal file
3
agent/tools/edit/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .edit import Edit
|
||||
|
||||
__all__ = ['Edit']
|
||||
164
agent/tools/edit/edit.py
Normal file
164
agent/tools/edit/edit.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Edit tool - Precise file editing
|
||||
Edit files through exact text replacement
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.diff import (
|
||||
strip_bom,
|
||||
detect_line_ending,
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string
|
||||
)
|
||||
|
||||
|
||||
class Edit(BaseTool):
|
||||
"""Tool for precise file editing"""
|
||||
|
||||
name: str = "edit"
|
||||
description: str = "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to edit (relative or absolute)"
|
||||
},
|
||||
"oldText": {
|
||||
"type": "string",
|
||||
"description": "Exact text to find and replace (must match exactly)"
|
||||
},
|
||||
"newText": {
|
||||
"type": "string",
|
||||
"description": "New text to replace the old text with"
|
||||
}
|
||||
},
|
||||
"required": ["path", "oldText", "newText"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute file edit operation
|
||||
|
||||
:param args: Contains file path, old text and new text
|
||||
:return: Operation result
|
||||
"""
|
||||
path = args.get("path", "").strip()
|
||||
old_text = args.get("oldText", "")
|
||||
new_text = args.get("newText", "")
|
||||
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_path):
|
||||
return ToolResult.fail(f"Error: File not found: {path}")
|
||||
|
||||
# Check if readable/writable
|
||||
if not os.access(absolute_path, os.R_OK | os.W_OK):
|
||||
return ToolResult.fail(f"Error: File is not readable/writable: {path}")
|
||||
|
||||
try:
|
||||
# Read file
|
||||
with open(absolute_path, 'r', encoding='utf-8') as f:
|
||||
raw_content = f.read()
|
||||
|
||||
# Remove BOM (LLM won't include invisible BOM in oldText)
|
||||
bom, content = strip_bom(raw_content)
|
||||
|
||||
# Detect original line ending
|
||||
original_ending = detect_line_ending(content)
|
||||
|
||||
# Normalize to LF
|
||||
normalized_content = normalize_to_lf(content)
|
||||
normalized_old_text = normalize_to_lf(old_text)
|
||||
normalized_new_text = normalize_to_lf(new_text)
|
||||
|
||||
# Use fuzzy matching to find old text (try exact match first, then fuzzy match)
|
||||
match_result = fuzzy_find_text(normalized_content, normalized_old_text)
|
||||
|
||||
if not match_result.found:
|
||||
return ToolResult.fail(
|
||||
f"Error: Could not find the exact text in {path}. "
|
||||
"The old text must match exactly including all whitespace and newlines."
|
||||
)
|
||||
|
||||
# Calculate occurrence count (use fuzzy normalized content for consistency)
|
||||
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
||||
occurrences = fuzzy_content.count(fuzzy_old_text)
|
||||
|
||||
if occurrences > 1:
|
||||
return ToolResult.fail(
|
||||
f"Error: Found {occurrences} occurrences of the text in {path}. "
|
||||
"The text must be unique. Please provide more context to make it unique."
|
||||
)
|
||||
|
||||
# Execute replacement (use matched text position)
|
||||
base_content = match_result.content_for_replacement
|
||||
new_content = (
|
||||
base_content[:match_result.index] +
|
||||
normalized_new_text +
|
||||
base_content[match_result.index + match_result.match_length:]
|
||||
)
|
||||
|
||||
# Verify replacement actually changed content
|
||||
if base_content == new_content:
|
||||
return ToolResult.fail(
|
||||
f"Error: No changes made to {path}. "
|
||||
"The replacement produced identical content. "
|
||||
"This might indicate an issue with special characters or the text not existing as expected."
|
||||
)
|
||||
|
||||
# Restore original line endings
|
||||
final_content = bom + restore_line_endings(new_content, original_ending)
|
||||
|
||||
# Write file
|
||||
with open(absolute_path, 'w', encoding='utf-8') as f:
|
||||
f.write(final_content)
|
||||
|
||||
# Generate diff
|
||||
diff_result = generate_diff_string(base_content, new_content)
|
||||
|
||||
result = {
|
||||
"message": f"Successfully replaced text in {path}",
|
||||
"path": path,
|
||||
"diff": diff_result['diff'],
|
||||
"first_changed_line": diff_result['first_changed_line']
|
||||
}
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
except UnicodeDecodeError:
|
||||
return ToolResult.fail(f"Error: File is not a valid text file (encoding error): {path}")
|
||||
except PermissionError:
|
||||
return ToolResult.fail(f"Error: Permission denied accessing {path}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error editing file: {str(e)}")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""
|
||||
Resolve path to absolute path
|
||||
|
||||
:param path: Relative or absolute path
|
||||
:return: Absolute path
|
||||
"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
3
agent/tools/file_save/__init__.py
Normal file
3
agent/tools/file_save/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .file_save import FileSave
|
||||
|
||||
__all__ = ['FileSave']
|
||||
770
agent/tools/file_save/file_save.py
Normal file
770
agent/tools/file_save/file_save.py
Normal file
@@ -0,0 +1,770 @@
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult, ToolStage
|
||||
from agent.models import LLMRequest
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class FileSave(BaseTool):
|
||||
"""Tool for saving content to files in the workspace directory."""
|
||||
|
||||
name = "file_save"
|
||||
description = "Save the agent's output to a file in the workspace directory. Content is automatically extracted from the agent's previous outputs."
|
||||
|
||||
# Set as post-process stage tool
|
||||
stage = ToolStage.POST_PROCESS
|
||||
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_name": {
|
||||
"type": "string",
|
||||
"description": "Optional. The name of the file to save. If not provided, a name will be generated based on the content."
|
||||
},
|
||||
"file_type": {
|
||||
"type": "string",
|
||||
"description": "Optional. The type/extension of the file (e.g., 'txt', 'md', 'py', 'java'). If not provided, it will be inferred from the content."
|
||||
},
|
||||
"extract_code": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If true, will attempt to extract code blocks from the content. Default is false."
|
||||
}
|
||||
},
|
||||
"required": [] # No required fields, as everything can be extracted from context
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.context = None
|
||||
self.config = {}
|
||||
self.workspace_dir = Path("workspace")
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Save content to a file in the workspace directory.
|
||||
|
||||
:param params: The parameters for the file output operation.
|
||||
:return: Result of the operation.
|
||||
"""
|
||||
# Extract content from context
|
||||
if not hasattr(self, 'context') or not self.context:
|
||||
return ToolResult.fail("Error: No context available to extract content from.")
|
||||
|
||||
content = self._extract_content_from_context()
|
||||
|
||||
# If no content could be extracted, return error
|
||||
if not content:
|
||||
return ToolResult.fail("Error: Couldn't extract content from context.")
|
||||
|
||||
# Use model to determine file parameters
|
||||
try:
|
||||
task_dir = self._get_task_dir_from_context()
|
||||
file_name, file_type, extract_code = self._get_file_params_from_model(content)
|
||||
except Exception as e:
|
||||
logger.error(f"Error determining file parameters: {str(e)}")
|
||||
# Fall back to manual parameter extraction
|
||||
task_dir = params.get("task_dir") or self._get_task_id_from_context() or f"task_{int(time.time())}"
|
||||
file_name = params.get("file_name") or self._infer_file_name(content)
|
||||
file_type = params.get("file_type") or self._infer_file_type(content)
|
||||
extract_code = params.get("extract_code", False)
|
||||
|
||||
# Get team_name from context
|
||||
team_name = self._get_team_name_from_context() or "default_team"
|
||||
|
||||
# Create directory structure
|
||||
task_dir_path = self.workspace_dir / team_name / task_dir
|
||||
task_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if extract_code:
|
||||
# Save the complete content as markdown
|
||||
md_file_name = f"{file_name}.md"
|
||||
md_file_path = task_dir_path / md_file_name
|
||||
|
||||
# Write content to file
|
||||
with open(md_file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
return self._handle_multiple_code_blocks(content)
|
||||
|
||||
# Ensure file_name has the correct extension
|
||||
if file_type and not file_name.endswith(f".{file_type}"):
|
||||
file_name = f"{file_name}.{file_type}"
|
||||
|
||||
# Create the full file path
|
||||
file_path = task_dir_path / file_name
|
||||
|
||||
# Get absolute path for storage in team_context
|
||||
abs_file_path = file_path.absolute()
|
||||
|
||||
try:
|
||||
# Write content to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
# Update the current agent's final_answer to include file information
|
||||
if hasattr(self.context, 'team_context'):
|
||||
# Store with absolute path in team_context
|
||||
self.context.team_context.agent_outputs[-1].output += f"\n\nSaved file: {abs_file_path}"
|
||||
|
||||
return ToolResult.success({
|
||||
"status": "success",
|
||||
"file_path": str(file_path) # Return relative path in result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error saving file: {str(e)}")
|
||||
|
||||
def _handle_multiple_code_blocks(self, content: str) -> ToolResult:
|
||||
"""
|
||||
Handle content with multiple code blocks, extracting and saving each as a separate file.
|
||||
|
||||
:param content: The content containing multiple code blocks
|
||||
:return: Result of the operation
|
||||
"""
|
||||
# Extract code blocks with context (including potential file name information)
|
||||
code_blocks_with_context = self._extract_code_blocks_with_context(content)
|
||||
|
||||
if not code_blocks_with_context:
|
||||
return ToolResult.fail("No code blocks found in the content.")
|
||||
|
||||
# Get task directory and team name
|
||||
task_dir = self._get_task_dir_from_context() or f"task_{int(time.time())}"
|
||||
team_name = self._get_team_name_from_context() or "default_team"
|
||||
|
||||
# Create directory structure
|
||||
task_dir_path = self.workspace_dir / team_name / task_dir
|
||||
task_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
saved_files = []
|
||||
|
||||
for block_with_context in code_blocks_with_context:
|
||||
try:
|
||||
# Use model to determine file name for this code block
|
||||
block_file_name, block_file_type = self._get_filename_for_code_block(block_with_context)
|
||||
|
||||
# Clean the code block (remove md code markers)
|
||||
clean_code = self._clean_code_block(block_with_context)
|
||||
|
||||
# Ensure file_name has the correct extension
|
||||
if block_file_type and not block_file_name.endswith(f".{block_file_type}"):
|
||||
block_file_name = f"{block_file_name}.{block_file_type}"
|
||||
|
||||
# Create the full file path (no subdirectories)
|
||||
file_path = task_dir_path / block_file_name
|
||||
|
||||
# Get absolute path for storage in team_context
|
||||
abs_file_path = file_path.absolute()
|
||||
|
||||
# Write content to file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(clean_code)
|
||||
|
||||
saved_files.append({
|
||||
"file_path": str(file_path),
|
||||
"abs_file_path": str(abs_file_path), # Store absolute path for internal use
|
||||
"file_name": block_file_name,
|
||||
"size": len(clean_code),
|
||||
"status": "success",
|
||||
"type": "code"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving code block: {str(e)}")
|
||||
# Continue with the next block even if this one fails
|
||||
|
||||
if not saved_files:
|
||||
return ToolResult.fail("Failed to save any code blocks.")
|
||||
|
||||
# Update the current agent's final_answer to include files information
|
||||
if hasattr(self, 'context') and self.context:
|
||||
# If the agent has a final_answer attribute, append the files info to it
|
||||
if hasattr(self.context, 'team_context'):
|
||||
# Use relative paths for display
|
||||
display_info = f"\n\nSaved files to {task_dir_path}:\n" + "\n".join(
|
||||
[f"- {f['file_path']}" for f in saved_files])
|
||||
|
||||
# Check if we need to append the info
|
||||
if not self.context.team_context.agent_outputs[-1].output.endswith(display_info):
|
||||
# Store with absolute paths in team_context
|
||||
abs_info = f"\n\nSaved files to {task_dir_path.absolute()}:\n" + "\n".join(
|
||||
[f"- {f['abs_file_path']}" for f in saved_files])
|
||||
self.context.team_context.agent_outputs[-1].output += abs_info
|
||||
|
||||
result = {
|
||||
"status": "success",
|
||||
"files": [{"file_path": f["file_path"]} for f in saved_files]
|
||||
}
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
def _extract_code_blocks_with_context(self, content: str) -> list:
|
||||
"""
|
||||
Extract code blocks from content, including context lines before the block.
|
||||
|
||||
:param content: The content to extract code blocks from
|
||||
:return: List of code blocks with context
|
||||
"""
|
||||
# Check if content starts with <!DOCTYPE or <html - likely a full HTML file
|
||||
if content.strip().startswith(("<!DOCTYPE", "<html", "<?xml")):
|
||||
return [content] # Return the entire content as a single block
|
||||
|
||||
# Split content into lines
|
||||
lines = content.split('\n')
|
||||
|
||||
blocks = []
|
||||
in_code_block = False
|
||||
current_block = []
|
||||
context_lines = []
|
||||
|
||||
# Check if there are any code block markers in the content
|
||||
if not re.search(r'```\w+', content):
|
||||
# If no code block markers and content looks like code, return the entire content
|
||||
if self._is_likely_code(content):
|
||||
return [content]
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith('```'):
|
||||
if in_code_block:
|
||||
# End of code block
|
||||
current_block.append(line)
|
||||
# Only add blocks that have a language specified
|
||||
block_content = '\n'.join(current_block)
|
||||
if re.search(r'```\w+', current_block[0]):
|
||||
# Combine context with code block
|
||||
blocks.append('\n'.join(context_lines + current_block))
|
||||
current_block = []
|
||||
context_lines = []
|
||||
in_code_block = False
|
||||
else:
|
||||
# Start of code block - check if it has a language specified
|
||||
if re.search(r'```\w+', line) and not re.search(r'```language=\s*$', line):
|
||||
# Start of code block with language
|
||||
in_code_block = True
|
||||
current_block = [line]
|
||||
# Keep only the last few context lines
|
||||
context_lines = context_lines[-5:] if context_lines else []
|
||||
|
||||
elif in_code_block:
|
||||
current_block.append(line)
|
||||
else:
|
||||
# Store context lines when not in a code block
|
||||
context_lines.append(line)
|
||||
|
||||
return blocks
|
||||
|
||||
def _get_filename_for_code_block(self, block_with_context: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Determine the file name for a code block.
|
||||
|
||||
:param block_with_context: The code block with context lines
|
||||
:return: Tuple of (file_name, file_type)
|
||||
"""
|
||||
# Define common code file extensions
|
||||
COMMON_CODE_EXTENSIONS = {
|
||||
'py', 'js', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rb', 'php',
|
||||
'html', 'css', 'ts', 'jsx', 'tsx', 'vue', 'sh', 'sql', 'json', 'xml',
|
||||
'yaml', 'yml', 'md', 'rs', 'swift', 'kt', 'scala', 'pl', 'r', 'lua'
|
||||
}
|
||||
|
||||
# Split the block into lines to examine only the context around code block markers
|
||||
lines = block_with_context.split('\n')
|
||||
|
||||
# Find the code block start marker line index
|
||||
start_marker_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('```') and not line.strip() == '```':
|
||||
start_marker_idx = i
|
||||
break
|
||||
|
||||
if start_marker_idx == -1:
|
||||
# No code block marker found
|
||||
return "", ""
|
||||
|
||||
# Extract the language from the code block marker
|
||||
code_marker = lines[start_marker_idx].strip()
|
||||
language = ""
|
||||
if len(code_marker) > 3:
|
||||
language = code_marker[3:].strip().split('=')[0].strip()
|
||||
|
||||
# Define the context range (5 lines before and 2 after the marker)
|
||||
context_start = max(0, start_marker_idx - 5)
|
||||
context_end = min(len(lines), start_marker_idx + 3)
|
||||
|
||||
# Extract only the relevant context lines
|
||||
context_lines = lines[context_start:context_end]
|
||||
|
||||
# First, check for explicit file headers like "## filename.ext"
|
||||
for line in context_lines:
|
||||
# Match patterns like "## filename.ext" or "# filename.ext"
|
||||
header_match = re.search(r'^\s*#{1,6}\s+([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)\s*$', line)
|
||||
if header_match:
|
||||
file_name = header_match.group(1)
|
||||
file_type = os.path.splitext(file_name)[1].lstrip('.')
|
||||
if file_type in COMMON_CODE_EXTENSIONS:
|
||||
return os.path.splitext(file_name)[0], file_type
|
||||
|
||||
# Simple patterns to match explicit file names in the context
|
||||
file_patterns = [
|
||||
# Match explicit file names in headers or text
|
||||
r'(?:file|filename)[:=\s]+[\'"]?([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)[\'"]?',
|
||||
# Match language=filename.ext in code markers
|
||||
r'language=([a-zA-Z0-9_-]+\.[a-zA-Z0-9]+)',
|
||||
# Match standalone filenames with extensions
|
||||
r'\b([a-zA-Z0-9_-]+\.(py|js|java|c|cpp|h|hpp|cs|go|rb|php|html|css|ts|jsx|tsx|vue|sh|sql|json|xml|yaml|yml|md|rs|swift|kt|scala|pl|r|lua))\b',
|
||||
# Match file paths in comments
|
||||
r'#\s*([a-zA-Z0-9_/-]+\.[a-zA-Z0-9]+)'
|
||||
]
|
||||
|
||||
# Check each context line for file name patterns
|
||||
for line in context_lines:
|
||||
line = line.strip()
|
||||
for pattern in file_patterns:
|
||||
matches = re.findall(pattern, line)
|
||||
if matches:
|
||||
for match in matches:
|
||||
if isinstance(match, tuple):
|
||||
# If the match is a tuple (filename, extension)
|
||||
file_name = match[0]
|
||||
file_type = match[1]
|
||||
# Verify it's not a code reference like Direction.DOWN
|
||||
if not any(keyword in file_name for keyword in ['class.', 'enum.', 'import.']):
|
||||
return os.path.splitext(file_name)[0], file_type
|
||||
else:
|
||||
# If the match is a string (full filename)
|
||||
file_name = match
|
||||
file_type = os.path.splitext(file_name)[1].lstrip('.')
|
||||
# Verify it's not a code reference
|
||||
if file_type in COMMON_CODE_EXTENSIONS and not any(
|
||||
keyword in file_name for keyword in ['class.', 'enum.', 'import.']):
|
||||
return os.path.splitext(file_name)[0], file_type
|
||||
|
||||
# If no explicit file name found, use LLM to infer from code content
|
||||
# Extract the code content
|
||||
code_content = block_with_context
|
||||
|
||||
# Get the first 20 lines of code for LLM analysis
|
||||
code_lines = code_content.split('\n')
|
||||
code_preview = '\n'.join(code_lines[:20])
|
||||
|
||||
# Get the model to use
|
||||
model_to_use = None
|
||||
if hasattr(self, 'context') and self.context:
|
||||
if hasattr(self.context, 'model') and self.context.model:
|
||||
model_to_use = self.context.model
|
||||
elif hasattr(self.context, 'team_context') and self.context.team_context:
|
||||
if hasattr(self.context.team_context, 'model') and self.context.team_context.model:
|
||||
model_to_use = self.context.team_context.model
|
||||
|
||||
# If no model is available in context, use the tool's model
|
||||
if not model_to_use and hasattr(self, 'model') and self.model:
|
||||
model_to_use = self.model
|
||||
|
||||
if model_to_use:
|
||||
# Prepare a prompt for the model
|
||||
prompt = f"""Analyze the following code and determine the most appropriate file name and file type/extension.
|
||||
The file name should be descriptive but concise, using snake_case (lowercase with underscores).
|
||||
The file type should be a standard file extension (e.g., py, js, html, css, java).
|
||||
|
||||
Code preview (first 20 lines):
|
||||
{code_preview}
|
||||
|
||||
Return your answer in JSON format with these fields:
|
||||
- file_name: The suggested file name (without extension)
|
||||
- file_type: The suggested file extension
|
||||
|
||||
JSON response:"""
|
||||
|
||||
# Create a request to the model
|
||||
request = LLMRequest(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0,
|
||||
json_format=True
|
||||
)
|
||||
|
||||
try:
|
||||
response = model_to_use.call(request)
|
||||
|
||||
if not response.is_error:
|
||||
# Clean the JSON response
|
||||
json_content = self._clean_json_response(response.data["choices"][0]["message"]["content"])
|
||||
result = json.loads(json_content)
|
||||
|
||||
file_name = result.get("file_name", "")
|
||||
file_type = result.get("file_type", "")
|
||||
|
||||
if file_name and file_type:
|
||||
return file_name, file_type
|
||||
except Exception as e:
|
||||
logger.error(f"Error using model to determine file name: {str(e)}")
|
||||
|
||||
# If we still don't have a file name, use the language as file type
|
||||
if language and language in COMMON_CODE_EXTENSIONS:
|
||||
timestamp = int(time.time())
|
||||
return f"code_{timestamp}", language
|
||||
|
||||
# If all else fails, return empty strings
|
||||
return "", ""
|
||||
|
||||
def _clean_json_response(self, text: str) -> str:
|
||||
"""
|
||||
Clean JSON response from LLM by removing markdown code block markers.
|
||||
|
||||
:param text: The text containing JSON possibly wrapped in markdown code blocks
|
||||
:return: Clean JSON string
|
||||
"""
|
||||
# Remove markdown code block markers if present
|
||||
if text.startswith("```json"):
|
||||
text = text[7:]
|
||||
elif text.startswith("```"):
|
||||
# Find the first newline to skip the language identifier line
|
||||
first_newline = text.find('\n')
|
||||
if first_newline != -1:
|
||||
text = text[first_newline + 1:]
|
||||
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
|
||||
return text.strip()
|
||||
|
||||
def _clean_code_block(self, block_with_context: str) -> str:
|
||||
"""
|
||||
Clean a code block by removing markdown code markers and context lines.
|
||||
|
||||
:param block_with_context: Code block with context lines
|
||||
:return: Clean code ready for execution
|
||||
"""
|
||||
# Check if this is a full HTML or XML document
|
||||
if block_with_context.strip().startswith(("<!DOCTYPE", "<html", "<?xml")):
|
||||
return block_with_context
|
||||
|
||||
# Find the code block
|
||||
code_block_match = re.search(r'```(?:\w+)?(?:[:=][^\n]+)?\n([\s\S]*?)\n```', block_with_context)
|
||||
|
||||
if code_block_match:
|
||||
return code_block_match.group(1)
|
||||
|
||||
# If no match found, try to extract anything between ``` markers
|
||||
lines = block_with_context.split('\n')
|
||||
start_idx = None
|
||||
end_idx = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('```'):
|
||||
if start_idx is None:
|
||||
start_idx = i
|
||||
else:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if start_idx is not None and end_idx is not None:
|
||||
# Extract the code between the markers, excluding the markers themselves
|
||||
code_lines = lines[start_idx + 1:end_idx]
|
||||
return '\n'.join(code_lines)
|
||||
|
||||
# If all else fails, return the original content
|
||||
return block_with_context
|
||||
|
||||
def _get_file_params_from_model(self, content, model=None):
|
||||
"""
|
||||
Use LLM to determine if the content is code and suggest appropriate file parameters.
|
||||
|
||||
Args:
|
||||
content: The content to analyze
|
||||
model: Optional model to use for the analysis
|
||||
|
||||
Returns:
|
||||
tuple: (file_name, file_type, extract_code) for backward compatibility
|
||||
"""
|
||||
if model is None:
|
||||
model = self.model
|
||||
|
||||
if not model:
|
||||
# Default fallback if no model is available
|
||||
return "output", "txt", False
|
||||
|
||||
prompt = f"""
|
||||
Analyze the following content and determine:
|
||||
1. Is this primarily code implementation (where most of the content consists of code blocks)?
|
||||
2. What would be an appropriate filename and file extension?
|
||||
|
||||
Content to analyze: ```
|
||||
{content[:500]} # Only show first 500 chars to avoid token limits ```
|
||||
|
||||
{"..." if len(content) > 500 else ""}
|
||||
|
||||
Respond in JSON format only with the following structure:
|
||||
{{
|
||||
"is_code": true/false, # Whether this is primarily code implementation
|
||||
"filename": "suggested_filename", # Don't include extension, english words
|
||||
"extension": "appropriate_extension" # Don't include the dot, e.g., "md", "py", "js"
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
# Create a request to the model
|
||||
request = LLMRequest(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1,
|
||||
json_format=True
|
||||
)
|
||||
|
||||
# Call the model using the standard interface
|
||||
response = model.call(request)
|
||||
|
||||
if response.is_error:
|
||||
logger.warning(f"Error from model: {response.error_message}")
|
||||
raise Exception(f"Model error: {response.error_message}")
|
||||
|
||||
# Extract JSON from response
|
||||
result = response.data["choices"][0]["message"]["content"]
|
||||
|
||||
# Clean the JSON response
|
||||
result = self._clean_json_response(result)
|
||||
|
||||
# Parse the JSON
|
||||
params = json.loads(result)
|
||||
|
||||
# For backward compatibility, return tuple format
|
||||
file_name = params.get("filename", "output")
|
||||
# Remove dot from extension if present
|
||||
file_type = params.get("extension", "md").lstrip(".")
|
||||
extract_code = params.get("is_code", False)
|
||||
|
||||
return file_name, file_type, extract_code
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting file parameters from model: {e}")
|
||||
# Default fallback
|
||||
return "output", "md", False
|
||||
|
||||
def _get_team_name_from_context(self) -> Optional[str]:
|
||||
"""
|
||||
Get team name from the agent's context.
|
||||
|
||||
:return: Team name or None if not found
|
||||
"""
|
||||
if hasattr(self, 'context') and self.context:
|
||||
# Try to get team name from team_context
|
||||
if hasattr(self.context, 'team_context') and self.context.team_context:
|
||||
return self.context.team_context.name
|
||||
|
||||
# Try direct team_name attribute
|
||||
if hasattr(self.context, 'name'):
|
||||
return self.context.name
|
||||
|
||||
return None
|
||||
|
||||
def _get_task_id_from_context(self) -> Optional[str]:
|
||||
"""
|
||||
Get task ID from the agent's context.
|
||||
|
||||
:return: Task ID or None if not found
|
||||
"""
|
||||
if hasattr(self, 'context') and self.context:
|
||||
# Try to get task ID from task object
|
||||
if hasattr(self.context, 'task') and self.context.task:
|
||||
return self.context.task.id
|
||||
|
||||
# Try team_context's task
|
||||
if hasattr(self.context, 'team_context') and self.context.team_context:
|
||||
if hasattr(self.context.team_context, 'task') and self.context.team_context.task:
|
||||
return self.context.team_context.task.id
|
||||
|
||||
return None
|
||||
|
||||
def _get_task_dir_from_context(self) -> Optional[str]:
|
||||
"""
|
||||
Get task directory name from the team context.
|
||||
|
||||
:return: Task directory name or None if not found
|
||||
"""
|
||||
if hasattr(self, 'context') and self.context:
|
||||
# Try to get from team_context
|
||||
if hasattr(self.context, 'team_context') and self.context.team_context:
|
||||
if hasattr(self.context.team_context, 'task_short_name') and self.context.team_context.task_short_name:
|
||||
return self.context.team_context.task_short_name
|
||||
|
||||
# Fall back to task ID if available
|
||||
return self._get_task_id_from_context()
|
||||
|
||||
def _extract_content_from_context(self) -> str:
|
||||
"""
|
||||
Extract content from the agent's context.
|
||||
|
||||
:return: Extracted content
|
||||
"""
|
||||
# Check if we have access to the agent's context
|
||||
if not hasattr(self, 'context') or not self.context:
|
||||
return ""
|
||||
|
||||
# Try to get the most recent final answer from the agent
|
||||
if hasattr(self.context, 'final_answer') and self.context.final_answer:
|
||||
return self.context.final_answer
|
||||
|
||||
# Try to get the most recent final answer from team context
|
||||
if hasattr(self.context, 'team_context') and self.context.team_context:
|
||||
if hasattr(self.context.team_context, 'agent_outputs') and self.context.team_context.agent_outputs:
|
||||
latest_output = self.context.team_context.agent_outputs[-1].output
|
||||
return latest_output
|
||||
|
||||
# If we have action history, try to get the most recent final answer
|
||||
if hasattr(self.context, 'action_history') and self.context.action_history:
|
||||
for action in reversed(self.context.action_history):
|
||||
if "final_answer" in action and action["final_answer"]:
|
||||
return action["final_answer"]
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_code_blocks(self, content: str) -> str:
|
||||
"""
|
||||
Extract code blocks from markdown content.
|
||||
|
||||
:param content: The content to extract code blocks from
|
||||
:return: Extracted code blocks
|
||||
"""
|
||||
# Pattern to match markdown code blocks
|
||||
code_block_pattern = r'```(?:\w+)?\n([\s\S]*?)\n```'
|
||||
|
||||
# Find all code blocks
|
||||
code_blocks = re.findall(code_block_pattern, content)
|
||||
|
||||
if code_blocks:
|
||||
# Join all code blocks with newlines
|
||||
return '\n\n'.join(code_blocks)
|
||||
|
||||
return content # Return original content if no code blocks found
|
||||
|
||||
def _infer_file_name(self, content: str) -> str:
|
||||
"""
|
||||
Infer a file name from the content.
|
||||
|
||||
:param content: The content to analyze.
|
||||
:return: A suggested file name.
|
||||
"""
|
||||
# Check for title patterns in markdown
|
||||
title_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
||||
if title_match:
|
||||
# Convert title to a valid filename
|
||||
title = title_match.group(1).strip()
|
||||
return self._sanitize_filename(title)
|
||||
|
||||
# Check for class/function definitions in code
|
||||
code_match = re.search(r'(class|def|function)\s+(\w+)', content)
|
||||
if code_match:
|
||||
return self._sanitize_filename(code_match.group(2))
|
||||
|
||||
# Default name based on content type
|
||||
if self._is_likely_code(content):
|
||||
return "code"
|
||||
elif self._is_likely_markdown(content):
|
||||
return "document"
|
||||
elif self._is_likely_json(content):
|
||||
return "data"
|
||||
else:
|
||||
return "output"
|
||||
|
||||
def _infer_file_type(self, content: str) -> str:
|
||||
"""
|
||||
Infer the file type/extension from the content.
|
||||
|
||||
:param content: The content to analyze.
|
||||
:return: A suggested file extension.
|
||||
"""
|
||||
# Check for common programming language patterns
|
||||
if re.search(r'(import\s+[a-zA-Z0-9_]+|from\s+[a-zA-Z0-9_\.]+\s+import)', content):
|
||||
return "py" # Python
|
||||
elif re.search(r'(public\s+class|private\s+class|protected\s+class)', content):
|
||||
return "java" # Java
|
||||
elif re.search(r'(function\s+\w+\s*\(|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=)', content):
|
||||
return "js" # JavaScript
|
||||
elif re.search(r'(<html|<body|<div|<p>)', content):
|
||||
return "html" # HTML
|
||||
elif re.search(r'(#include\s+<\w+\.h>|int\s+main\s*\()', content):
|
||||
return "cpp" # C/C++
|
||||
|
||||
# Check for markdown
|
||||
if self._is_likely_markdown(content):
|
||||
return "md"
|
||||
|
||||
# Check for JSON
|
||||
if self._is_likely_json(content):
|
||||
return "json"
|
||||
|
||||
# Default to text
|
||||
return "txt"
|
||||
|
||||
def _is_likely_code(self, content: str) -> bool:
|
||||
"""Check if the content is likely code."""
|
||||
# First check for common HTML/XML patterns
|
||||
if content.strip().startswith(("<!DOCTYPE", "<html", "<?xml", "<head", "<body")):
|
||||
return True
|
||||
|
||||
code_patterns = [
|
||||
r'(class|def|function|import|from|public|private|protected|#include)',
|
||||
r'(\{\s*\n|\}\s*\n|\[\s*\n|\]\s*\n)',
|
||||
r'(if\s*\(|for\s*\(|while\s*\()',
|
||||
r'(<\w+>.*?</\w+>)', # HTML/XML tags
|
||||
r'(var|let|const)\s+\w+\s*=', # JavaScript variable declarations
|
||||
r'#\s*\w+', # CSS ID selectors or Python comments
|
||||
r'\.\w+\s*\{', # CSS class selectors
|
||||
r'@media|@import|@font-face' # CSS at-rules
|
||||
]
|
||||
return any(re.search(pattern, content) for pattern in code_patterns)
|
||||
|
||||
def _is_likely_markdown(self, content: str) -> bool:
|
||||
"""Check if the content is likely markdown."""
|
||||
md_patterns = [
|
||||
r'^#\s+.+$', # Headers
|
||||
r'^\*\s+.+$', # Unordered lists
|
||||
r'^\d+\.\s+.+$', # Ordered lists
|
||||
r'\[.+\]\(.+\)', # Links
|
||||
r'!\[.+\]\(.+\)' # Images
|
||||
]
|
||||
return any(re.search(pattern, content, re.MULTILINE) for pattern in md_patterns)
|
||||
|
||||
def _is_likely_json(self, content: str) -> bool:
|
||||
"""Check if the content is likely JSON."""
|
||||
try:
|
||||
content = content.strip()
|
||||
if (content.startswith('{') and content.endswith('}')) or (
|
||||
content.startswith('[') and content.endswith(']')):
|
||||
json.loads(content)
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _sanitize_filename(self, name: str) -> str:
|
||||
"""
|
||||
Sanitize a string to be used as a filename.
|
||||
|
||||
:param name: The string to sanitize.
|
||||
:return: A sanitized filename.
|
||||
"""
|
||||
# Replace spaces with underscores
|
||||
name = name.replace(' ', '_')
|
||||
|
||||
# Remove invalid characters
|
||||
name = re.sub(r'[^\w\-\.]', '', name)
|
||||
|
||||
# Limit length
|
||||
if len(name) > 50:
|
||||
name = name[:50]
|
||||
|
||||
return name.lower()
|
||||
|
||||
def _process_file_path(self, file_path: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Process a file path to extract the file name and type, and create directories if needed.
|
||||
|
||||
:param file_path: The file path to process
|
||||
:return: Tuple of (file_name, file_type)
|
||||
"""
|
||||
# Get the file name and extension
|
||||
file_name = os.path.basename(file_path)
|
||||
file_type = os.path.splitext(file_name)[1].lstrip('.')
|
||||
|
||||
return os.path.splitext(file_name)[0], file_type
|
||||
3
agent/tools/find/__init__.py
Normal file
3
agent/tools/find/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .find import Find
|
||||
|
||||
__all__ = ['Find']
|
||||
177
agent/tools/find/find.py
Normal file
177
agent/tools/find/find.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Find tool - Search for files by glob pattern
|
||||
"""
|
||||
|
||||
import os
|
||||
import glob as glob_module
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
DEFAULT_LIMIT = 1000
|
||||
|
||||
|
||||
class Find(BaseTool):
|
||||
"""Tool for finding files by pattern"""
|
||||
|
||||
name: str = "find"
|
||||
description: str = f"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} results or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first)."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in (default: current directory)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": f"Maximum number of results (default: {DEFAULT_LIMIT})"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute file search
|
||||
|
||||
:param args: Search parameters
|
||||
:return: Search results or error
|
||||
"""
|
||||
pattern = args.get("pattern", "").strip()
|
||||
search_path = args.get("path", ".").strip()
|
||||
limit = args.get("limit", DEFAULT_LIMIT)
|
||||
|
||||
if not pattern:
|
||||
return ToolResult.fail("Error: pattern parameter is required")
|
||||
|
||||
# Resolve search path
|
||||
absolute_path = self._resolve_path(search_path)
|
||||
|
||||
if not os.path.exists(absolute_path):
|
||||
return ToolResult.fail(f"Error: Path not found: {search_path}")
|
||||
|
||||
if not os.path.isdir(absolute_path):
|
||||
return ToolResult.fail(f"Error: Not a directory: {search_path}")
|
||||
|
||||
try:
|
||||
# Load .gitignore patterns
|
||||
ignore_patterns = self._load_gitignore(absolute_path)
|
||||
|
||||
# Search for files
|
||||
results = []
|
||||
search_pattern = os.path.join(absolute_path, pattern)
|
||||
|
||||
# Use glob with recursive support
|
||||
for file_path in glob_module.glob(search_pattern, recursive=True):
|
||||
# Skip if matches ignore patterns
|
||||
if self._should_ignore(file_path, absolute_path, ignore_patterns):
|
||||
continue
|
||||
|
||||
# Get relative path
|
||||
relative_path = os.path.relpath(file_path, absolute_path)
|
||||
|
||||
# Add trailing slash for directories
|
||||
if os.path.isdir(file_path):
|
||||
relative_path += '/'
|
||||
|
||||
results.append(relative_path)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
if not results:
|
||||
return ToolResult.success({"message": "No files found matching pattern", "files": []})
|
||||
|
||||
# Sort results
|
||||
results.sort()
|
||||
|
||||
# Format output
|
||||
raw_output = '\n'.join(results)
|
||||
truncation = truncate_head(raw_output, max_lines=999999) # Only limit by bytes
|
||||
|
||||
output = truncation.content
|
||||
details = {}
|
||||
notices = []
|
||||
|
||||
result_limit_reached = len(results) >= limit
|
||||
if result_limit_reached:
|
||||
notices.append(f"{limit} results limit reached. Use limit={limit * 2} for more, or refine pattern")
|
||||
details["result_limit_reached"] = limit
|
||||
|
||||
if truncation.truncated:
|
||||
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
||||
details["truncation"] = truncation.to_dict()
|
||||
|
||||
if notices:
|
||||
output += f"\n\n[{'. '.join(notices)}]"
|
||||
|
||||
return ToolResult.success({
|
||||
"output": output,
|
||||
"file_count": len(results),
|
||||
"details": details if details else None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error executing find: {str(e)}")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Resolve path to absolute path"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
|
||||
def _load_gitignore(self, directory: str) -> List[str]:
|
||||
"""Load .gitignore patterns from directory"""
|
||||
patterns = []
|
||||
gitignore_path = os.path.join(directory, '.gitignore')
|
||||
|
||||
if os.path.exists(gitignore_path):
|
||||
try:
|
||||
with open(gitignore_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
patterns.append(line)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add common ignore patterns
|
||||
patterns.extend([
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'*.pyc',
|
||||
'node_modules',
|
||||
'.DS_Store'
|
||||
])
|
||||
|
||||
return patterns
|
||||
|
||||
def _should_ignore(self, file_path: str, base_path: str, patterns: List[str]) -> bool:
|
||||
"""Check if file should be ignored based on patterns"""
|
||||
relative_path = os.path.relpath(file_path, base_path)
|
||||
|
||||
for pattern in patterns:
|
||||
# Simple pattern matching
|
||||
if pattern in relative_path:
|
||||
return True
|
||||
|
||||
# Check if it's a directory pattern
|
||||
if pattern.endswith('/'):
|
||||
if relative_path.startswith(pattern.rstrip('/')):
|
||||
return True
|
||||
|
||||
return False
|
||||
48
agent/tools/google_search/google_search.py
Normal file
48
agent/tools/google_search/google_search.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import requests
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class GoogleSearch(BaseTool):
|
||||
name: str = "google_search"
|
||||
description: str = "A tool to perform Google searches using the Serper API."
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to perform."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
config: dict = {}
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or {}
|
||||
|
||||
def execute(self, args: dict) -> ToolResult:
|
||||
api_key = self.config.get("api_key") # Replace with your actual API key
|
||||
url = "https://google.serper.dev/search"
|
||||
headers = {
|
||||
"X-API-KEY": api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"q": args.get("query"),
|
||||
"k": 10
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
result = response.json()
|
||||
|
||||
if result.get("statusCode") and result.get("statusCode") == 503:
|
||||
return ToolResult.fail(result=result)
|
||||
else:
|
||||
# Check if the returned result contains the 'organic' key and ensure it is a list
|
||||
if 'organic' in result and isinstance(result.get('organic'), list):
|
||||
result_data = result['organic']
|
||||
else:
|
||||
# If there are no organic results, return the full response or an empty list
|
||||
result_data = result.get('organic', []) if isinstance(result.get('organic'), list) else []
|
||||
return ToolResult.success(result=result_data)
|
||||
3
agent/tools/grep/__init__.py
Normal file
3
agent/tools/grep/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .grep import Grep
|
||||
|
||||
__all__ = ['Grep']
|
||||
248
agent/tools/grep/grep.py
Normal file
248
agent/tools/grep/grep.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
Grep tool - Search file contents for patterns
|
||||
Uses ripgrep (rg) for fast searching
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import (
|
||||
truncate_head, truncate_line, format_size,
|
||||
DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_LIMIT = 100
|
||||
|
||||
|
||||
class Grep(BaseTool):
|
||||
"""Tool for searching file contents"""
|
||||
|
||||
name: str = "grep"
|
||||
description: str = f"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} matches or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). Long lines are truncated to {GREP_MAX_LINE_LENGTH} chars."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (regex or literal string)"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory or file to search (default: current directory)"
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"
|
||||
},
|
||||
"ignoreCase": {
|
||||
"type": "boolean",
|
||||
"description": "Case-insensitive search (default: false)"
|
||||
},
|
||||
"literal": {
|
||||
"type": "boolean",
|
||||
"description": "Treat pattern as literal string instead of regex (default: false)"
|
||||
},
|
||||
"context": {
|
||||
"type": "integer",
|
||||
"description": "Number of lines to show before and after each match (default: 0)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": f"Maximum number of matches to return (default: {DEFAULT_LIMIT})"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
self.rg_path = self._find_ripgrep()
|
||||
|
||||
def _find_ripgrep(self) -> Optional[str]:
|
||||
"""Find ripgrep executable"""
|
||||
try:
|
||||
result = subprocess.run(['which', 'rg'], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute grep search
|
||||
|
||||
:param args: Search parameters
|
||||
:return: Search results or error
|
||||
"""
|
||||
if not self.rg_path:
|
||||
return ToolResult.fail("Error: ripgrep (rg) is not installed. Please install it first.")
|
||||
|
||||
pattern = args.get("pattern", "").strip()
|
||||
search_path = args.get("path", ".").strip()
|
||||
glob = args.get("glob")
|
||||
ignore_case = args.get("ignoreCase", False)
|
||||
literal = args.get("literal", False)
|
||||
context = args.get("context", 0)
|
||||
limit = args.get("limit", DEFAULT_LIMIT)
|
||||
|
||||
if not pattern:
|
||||
return ToolResult.fail("Error: pattern parameter is required")
|
||||
|
||||
# Resolve search path
|
||||
absolute_path = self._resolve_path(search_path)
|
||||
|
||||
if not os.path.exists(absolute_path):
|
||||
return ToolResult.fail(f"Error: Path not found: {search_path}")
|
||||
|
||||
# Build ripgrep command
|
||||
cmd = [
|
||||
self.rg_path,
|
||||
'--json',
|
||||
'--line-number',
|
||||
'--color=never',
|
||||
'--hidden'
|
||||
]
|
||||
|
||||
if ignore_case:
|
||||
cmd.append('--ignore-case')
|
||||
|
||||
if literal:
|
||||
cmd.append('--fixed-strings')
|
||||
|
||||
if glob:
|
||||
cmd.extend(['--glob', glob])
|
||||
|
||||
cmd.extend([pattern, absolute_path])
|
||||
|
||||
try:
|
||||
# Execute ripgrep
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=self.cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Parse JSON output
|
||||
matches = []
|
||||
match_count = 0
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if event.get('type') == 'match':
|
||||
data = event.get('data', {})
|
||||
file_path = data.get('path', {}).get('text')
|
||||
line_number = data.get('line_number')
|
||||
|
||||
if file_path and line_number:
|
||||
matches.append({
|
||||
'file': file_path,
|
||||
'line': line_number
|
||||
})
|
||||
match_count += 1
|
||||
|
||||
if match_count >= limit:
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if match_count == 0:
|
||||
return ToolResult.success({"message": "No matches found", "matches": []})
|
||||
|
||||
# Format output with context
|
||||
output_lines = []
|
||||
lines_truncated = False
|
||||
is_directory = os.path.isdir(absolute_path)
|
||||
|
||||
for match in matches:
|
||||
file_path = match['file']
|
||||
line_number = match['line']
|
||||
|
||||
# Format file path
|
||||
if is_directory:
|
||||
relative_path = os.path.relpath(file_path, absolute_path)
|
||||
else:
|
||||
relative_path = os.path.basename(file_path)
|
||||
|
||||
# Read file and get context
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
file_lines = f.read().split('\n')
|
||||
|
||||
# Calculate context range
|
||||
start = max(0, line_number - 1 - context) if context > 0 else line_number - 1
|
||||
end = min(len(file_lines), line_number + context) if context > 0 else line_number
|
||||
|
||||
# Format lines with context
|
||||
for i in range(start, end):
|
||||
line_text = file_lines[i].replace('\r', '')
|
||||
|
||||
# Truncate long lines
|
||||
truncated_text, was_truncated = truncate_line(line_text)
|
||||
if was_truncated:
|
||||
lines_truncated = True
|
||||
|
||||
# Format output
|
||||
current_line = i + 1
|
||||
if current_line == line_number:
|
||||
output_lines.append(f"{relative_path}:{current_line}: {truncated_text}")
|
||||
else:
|
||||
output_lines.append(f"{relative_path}-{current_line}- {truncated_text}")
|
||||
|
||||
except Exception:
|
||||
output_lines.append(f"{relative_path}:{line_number}: (unable to read file)")
|
||||
|
||||
# Apply byte truncation
|
||||
raw_output = '\n'.join(output_lines)
|
||||
truncation = truncate_head(raw_output, max_lines=999999) # Only limit by bytes
|
||||
|
||||
output = truncation.content
|
||||
details = {}
|
||||
notices = []
|
||||
|
||||
if match_count >= limit:
|
||||
notices.append(f"{limit} matches limit reached. Use limit={limit * 2} for more, or refine pattern")
|
||||
details["match_limit_reached"] = limit
|
||||
|
||||
if truncation.truncated:
|
||||
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
||||
details["truncation"] = truncation.to_dict()
|
||||
|
||||
if lines_truncated:
|
||||
notices.append(f"Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines")
|
||||
details["lines_truncated"] = True
|
||||
|
||||
if notices:
|
||||
output += f"\n\n[{'. '.join(notices)}]"
|
||||
|
||||
return ToolResult.success({
|
||||
"output": output,
|
||||
"match_count": match_count,
|
||||
"details": details if details else None
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ToolResult.fail("Error: Search timed out after 30 seconds")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error executing grep: {str(e)}")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Resolve path to absolute path"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
3
agent/tools/ls/__init__.py
Normal file
3
agent/tools/ls/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .ls import Ls
|
||||
|
||||
__all__ = ['Ls']
|
||||
125
agent/tools/ls/ls.py
Normal file
125
agent/tools/ls/ls.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Ls tool - List directory contents
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
DEFAULT_LIMIT = 500
|
||||
|
||||
|
||||
class Ls(BaseTool):
|
||||
"""Tool for listing directory contents"""
|
||||
|
||||
name: str = "ls"
|
||||
description: str = f"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to {DEFAULT_LIMIT} entries or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first)."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to list (default: current directory)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": f"Maximum number of entries to return (default: {DEFAULT_LIMIT})"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute directory listing
|
||||
|
||||
:param args: Listing parameters
|
||||
:return: Directory contents or error
|
||||
"""
|
||||
path = args.get("path", ".").strip()
|
||||
limit = args.get("limit", DEFAULT_LIMIT)
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
if not os.path.exists(absolute_path):
|
||||
return ToolResult.fail(f"Error: Path not found: {path}")
|
||||
|
||||
if not os.path.isdir(absolute_path):
|
||||
return ToolResult.fail(f"Error: Not a directory: {path}")
|
||||
|
||||
try:
|
||||
# Read directory entries
|
||||
entries = os.listdir(absolute_path)
|
||||
|
||||
# Sort alphabetically (case-insensitive)
|
||||
entries.sort(key=lambda x: x.lower())
|
||||
|
||||
# Format entries with directory indicators
|
||||
results = []
|
||||
entry_limit_reached = False
|
||||
|
||||
for entry in entries:
|
||||
if len(results) >= limit:
|
||||
entry_limit_reached = True
|
||||
break
|
||||
|
||||
full_path = os.path.join(absolute_path, entry)
|
||||
|
||||
try:
|
||||
if os.path.isdir(full_path):
|
||||
results.append(entry + '/')
|
||||
else:
|
||||
results.append(entry)
|
||||
except:
|
||||
# Skip entries we can't stat
|
||||
continue
|
||||
|
||||
if not results:
|
||||
return ToolResult.success({"message": "(empty directory)", "entries": []})
|
||||
|
||||
# Format output
|
||||
raw_output = '\n'.join(results)
|
||||
truncation = truncate_head(raw_output, max_lines=999999) # Only limit by bytes
|
||||
|
||||
output = truncation.content
|
||||
details = {}
|
||||
notices = []
|
||||
|
||||
if entry_limit_reached:
|
||||
notices.append(f"{limit} entries limit reached. Use limit={limit * 2} for more")
|
||||
details["entry_limit_reached"] = limit
|
||||
|
||||
if truncation.truncated:
|
||||
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
||||
details["truncation"] = truncation.to_dict()
|
||||
|
||||
if notices:
|
||||
output += f"\n\n[{'. '.join(notices)}]"
|
||||
|
||||
return ToolResult.success({
|
||||
"output": output,
|
||||
"entry_count": len(results),
|
||||
"details": details if details else None
|
||||
})
|
||||
|
||||
except PermissionError:
|
||||
return ToolResult.fail(f"Error: Permission denied reading directory: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error listing directory: {str(e)}")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Resolve path to absolute path"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
10
agent/tools/memory/__init__.py
Normal file
10
agent/tools/memory/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Memory tools for Agent
|
||||
|
||||
Provides memory_search and memory_get tools
|
||||
"""
|
||||
|
||||
from agent.tools.memory.memory_search import MemorySearchTool
|
||||
from agent.tools.memory.memory_get import MemoryGetTool
|
||||
|
||||
__all__ = ['MemorySearchTool', 'MemoryGetTool']
|
||||
107
agent/tools/memory/memory_get.py
Normal file
107
agent/tools/memory/memory_get.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Memory get tool
|
||||
|
||||
Allows agents to read specific sections from memory files
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
from agent.tools.base_tool import BaseTool
|
||||
|
||||
|
||||
class MemoryGetTool(BaseTool):
|
||||
"""Tool for reading memory file contents"""
|
||||
|
||||
name: str = "memory_get"
|
||||
description: str = (
|
||||
"Read specific content from memory files. "
|
||||
"Use this to get full context from a memory file or specific line range."
|
||||
)
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relative path to the memory file (e.g., 'MEMORY.md', 'memory/2024-01-29.md')"
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "Starting line number (optional, default: 1)",
|
||||
"default": 1
|
||||
},
|
||||
"num_lines": {
|
||||
"type": "integer",
|
||||
"description": "Number of lines to read (optional, reads all if not specified)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
|
||||
def __init__(self, memory_manager):
|
||||
"""
|
||||
Initialize memory get tool
|
||||
|
||||
Args:
|
||||
memory_manager: MemoryManager instance
|
||||
"""
|
||||
super().__init__()
|
||||
self.memory_manager = memory_manager
|
||||
|
||||
def execute(self, args: dict):
|
||||
"""
|
||||
Execute memory file read
|
||||
|
||||
Args:
|
||||
args: Dictionary with path, start_line, num_lines
|
||||
|
||||
Returns:
|
||||
ToolResult with file content
|
||||
"""
|
||||
from agent.tools.base_tool import ToolResult
|
||||
|
||||
path = args.get("path")
|
||||
start_line = args.get("start_line", 1)
|
||||
num_lines = args.get("num_lines")
|
||||
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
try:
|
||||
workspace_dir = self.memory_manager.config.get_workspace()
|
||||
file_path = workspace_dir / path
|
||||
|
||||
if not file_path.exists():
|
||||
return ToolResult.fail(f"Error: File not found: {path}")
|
||||
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
# Handle line range
|
||||
if start_line < 1:
|
||||
start_line = 1
|
||||
|
||||
start_idx = start_line - 1
|
||||
|
||||
if num_lines:
|
||||
end_idx = start_idx + num_lines
|
||||
selected_lines = lines[start_idx:end_idx]
|
||||
else:
|
||||
selected_lines = lines[start_idx:]
|
||||
|
||||
result = '\n'.join(selected_lines)
|
||||
|
||||
# Add metadata
|
||||
total_lines = len(lines)
|
||||
shown_lines = len(selected_lines)
|
||||
|
||||
output = [
|
||||
f"File: {path}",
|
||||
f"Lines: {start_line}-{start_line + shown_lines - 1} (total: {total_lines})",
|
||||
"",
|
||||
result
|
||||
]
|
||||
|
||||
return ToolResult.success('\n'.join(output))
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading memory file: {str(e)}")
|
||||
96
agent/tools/memory/memory_search.py
Normal file
96
agent/tools/memory/memory_search.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Memory search tool
|
||||
|
||||
Allows agents to search their memory using semantic and keyword search
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from agent.tools.base_tool import BaseTool
|
||||
|
||||
|
||||
class MemorySearchTool(BaseTool):
|
||||
"""Tool for searching agent memory"""
|
||||
|
||||
name: str = "memory_search"
|
||||
description: str = (
|
||||
"Search agent's long-term memory using semantic and keyword search. "
|
||||
"Use this to recall past conversations, preferences, and knowledge."
|
||||
)
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (can be natural language question or keywords)"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default: 10)",
|
||||
"default": 10
|
||||
},
|
||||
"min_score": {
|
||||
"type": "number",
|
||||
"description": "Minimum relevance score (0-1, default: 0.3)",
|
||||
"default": 0.3
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
|
||||
def __init__(self, memory_manager, user_id: Optional[str] = None):
|
||||
"""
|
||||
Initialize memory search tool
|
||||
|
||||
Args:
|
||||
memory_manager: MemoryManager instance
|
||||
user_id: Optional user ID for scoped search
|
||||
"""
|
||||
super().__init__()
|
||||
self.memory_manager = memory_manager
|
||||
self.user_id = user_id
|
||||
|
||||
def execute(self, args: dict):
|
||||
"""
|
||||
Execute memory search
|
||||
|
||||
Args:
|
||||
args: Dictionary with query, max_results, min_score
|
||||
|
||||
Returns:
|
||||
ToolResult with formatted search results
|
||||
"""
|
||||
from agent.tools.base_tool import ToolResult
|
||||
import asyncio
|
||||
|
||||
query = args.get("query")
|
||||
max_results = args.get("max_results", 10)
|
||||
min_score = args.get("min_score", 0.3)
|
||||
|
||||
if not query:
|
||||
return ToolResult.fail("Error: query parameter is required")
|
||||
|
||||
try:
|
||||
# Run async search in sync context
|
||||
results = asyncio.run(self.memory_manager.search(
|
||||
query=query,
|
||||
user_id=self.user_id,
|
||||
max_results=max_results,
|
||||
min_score=min_score,
|
||||
include_shared=True
|
||||
))
|
||||
|
||||
if not results:
|
||||
return ToolResult.success(f"No relevant memories found for query: {query}")
|
||||
|
||||
# Format results
|
||||
output = [f"Found {len(results)} relevant memories:\n"]
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
output.append(f"\n{i}. {result.path} (lines {result.start_line}-{result.end_line})")
|
||||
output.append(f" Score: {result.score:.3f}")
|
||||
output.append(f" Snippet: {result.snippet}")
|
||||
|
||||
return ToolResult.success("\n".join(output))
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error searching memory: {str(e)}")
|
||||
3
agent/tools/read/__init__.py
Normal file
3
agent/tools/read/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .read import Read
|
||||
|
||||
__all__ = ['Read']
|
||||
336
agent/tools/read/read.py
Normal file
336
agent/tools/read/read.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Read tool - Read file contents
|
||||
Supports text files, images (jpg, png, gif, webp), and PDF files
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
|
||||
|
||||
|
||||
class Read(BaseTool):
|
||||
"""Tool for reading file contents"""
|
||||
|
||||
name: str = "read"
|
||||
description: str = f"Read the contents of a file. Supports text files, PDF files, and images (jpg, png, gif, webp). For text files, output is truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). Use offset/limit for large files."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read (relative or absolute)"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Line number to start reading from (1-indexed, optional)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to read (optional)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
# Supported image formats
|
||||
self.image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
|
||||
# Supported PDF format
|
||||
self.pdf_extensions = {'.pdf'}
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute file read operation
|
||||
|
||||
:param args: Contains file path and optional offset/limit parameters
|
||||
:return: File content or error message
|
||||
"""
|
||||
path = args.get("path", "").strip()
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_path):
|
||||
return ToolResult.fail(f"Error: File not found: {path}")
|
||||
|
||||
# Check if readable
|
||||
if not os.access(absolute_path, os.R_OK):
|
||||
return ToolResult.fail(f"Error: File is not readable: {path}")
|
||||
|
||||
# Check file type
|
||||
file_ext = Path(absolute_path).suffix.lower()
|
||||
|
||||
# Check if image
|
||||
if file_ext in self.image_extensions:
|
||||
return self._read_image(absolute_path, file_ext)
|
||||
|
||||
# Check if PDF
|
||||
if file_ext in self.pdf_extensions:
|
||||
return self._read_pdf(absolute_path, path, offset, limit)
|
||||
|
||||
# Read text file
|
||||
return self._read_text(absolute_path, path, offset, limit)
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""
|
||||
Resolve path to absolute path
|
||||
|
||||
:param path: Relative or absolute path
|
||||
:return: Absolute path
|
||||
"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
|
||||
def _read_image(self, absolute_path: str, file_ext: str) -> ToolResult:
|
||||
"""
|
||||
Read image file
|
||||
|
||||
:param absolute_path: Absolute path to the image file
|
||||
:param file_ext: File extension
|
||||
:return: Result containing image information
|
||||
"""
|
||||
try:
|
||||
# Read image file
|
||||
with open(absolute_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
# Get file size
|
||||
file_size = len(image_data)
|
||||
|
||||
# Return image information (actual image data can be base64 encoded when needed)
|
||||
import base64
|
||||
base64_data = base64.b64encode(image_data).decode('utf-8')
|
||||
|
||||
# Determine MIME type
|
||||
mime_type_map = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
mime_type = mime_type_map.get(file_ext, 'image/jpeg')
|
||||
|
||||
result = {
|
||||
"type": "image",
|
||||
"mime_type": mime_type,
|
||||
"size": file_size,
|
||||
"size_formatted": format_size(file_size),
|
||||
"data": base64_data # Base64 encoded image data
|
||||
}
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading image file: {str(e)}")
|
||||
|
||||
def _read_text(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult:
|
||||
"""
|
||||
Read text file
|
||||
|
||||
:param absolute_path: Absolute path to the file
|
||||
:param display_path: Path to display
|
||||
:param offset: Starting line number (1-indexed)
|
||||
:param limit: Maximum number of lines to read
|
||||
:return: File content or error message
|
||||
"""
|
||||
try:
|
||||
# Read file
|
||||
with open(absolute_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
all_lines = content.split('\n')
|
||||
total_file_lines = len(all_lines)
|
||||
|
||||
# Apply offset (if specified)
|
||||
start_line = 0
|
||||
if offset is not None:
|
||||
start_line = max(0, offset - 1) # Convert to 0-indexed
|
||||
if start_line >= total_file_lines:
|
||||
return ToolResult.fail(
|
||||
f"Error: Offset {offset} is beyond end of file ({total_file_lines} lines total)"
|
||||
)
|
||||
|
||||
start_line_display = start_line + 1 # For display (1-indexed)
|
||||
|
||||
# If user specified limit, use it
|
||||
selected_content = content
|
||||
user_limited_lines = None
|
||||
if limit is not None:
|
||||
end_line = min(start_line + limit, total_file_lines)
|
||||
selected_content = '\n'.join(all_lines[start_line:end_line])
|
||||
user_limited_lines = end_line - start_line
|
||||
elif offset is not None:
|
||||
selected_content = '\n'.join(all_lines[start_line:])
|
||||
|
||||
# Apply truncation (considering line count and byte limits)
|
||||
truncation = truncate_head(selected_content)
|
||||
|
||||
output_text = ""
|
||||
details = {}
|
||||
|
||||
if truncation.first_line_exceeds_limit:
|
||||
# First line exceeds 30KB limit
|
||||
first_line_size = format_size(len(all_lines[start_line].encode('utf-8')))
|
||||
output_text = f"[Line {start_line_display} is {first_line_size}, exceeds {format_size(DEFAULT_MAX_BYTES)} limit. Use bash tool to read: head -c {DEFAULT_MAX_BYTES} {display_path} | tail -n +{start_line_display}]"
|
||||
details["truncation"] = truncation.to_dict()
|
||||
elif truncation.truncated:
|
||||
# Truncation occurred
|
||||
end_line_display = start_line_display + truncation.output_lines - 1
|
||||
next_offset = end_line_display + 1
|
||||
|
||||
output_text = truncation.content
|
||||
|
||||
if truncation.truncated_by == "lines":
|
||||
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines}. Use offset={next_offset} to continue.]"
|
||||
else:
|
||||
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_file_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Use offset={next_offset} to continue.]"
|
||||
|
||||
details["truncation"] = truncation.to_dict()
|
||||
elif user_limited_lines is not None and start_line + user_limited_lines < total_file_lines:
|
||||
# User specified limit, more content available, but no truncation
|
||||
remaining = total_file_lines - (start_line + user_limited_lines)
|
||||
next_offset = start_line + user_limited_lines + 1
|
||||
|
||||
output_text = truncation.content
|
||||
output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
|
||||
else:
|
||||
# No truncation, no exceeding user limit
|
||||
output_text = truncation.content
|
||||
|
||||
result = {
|
||||
"content": output_text,
|
||||
"total_lines": total_file_lines,
|
||||
"start_line": start_line_display,
|
||||
"output_lines": truncation.output_lines
|
||||
}
|
||||
|
||||
if details:
|
||||
result["details"] = details
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
except UnicodeDecodeError:
|
||||
return ToolResult.fail(f"Error: File is not a valid text file (encoding error): {display_path}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading file: {str(e)}")
|
||||
|
||||
def _read_pdf(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult:
|
||||
"""
|
||||
Read PDF file content
|
||||
|
||||
:param absolute_path: Absolute path to the file
|
||||
:param display_path: Path to display
|
||||
:param offset: Starting line number (1-indexed)
|
||||
:param limit: Maximum number of lines to read
|
||||
:return: PDF text content or error message
|
||||
"""
|
||||
try:
|
||||
# Try to import pypdf
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
return ToolResult.fail(
|
||||
"Error: pypdf library not installed. Install with: pip install pypdf"
|
||||
)
|
||||
|
||||
# Read PDF
|
||||
reader = PdfReader(absolute_path)
|
||||
total_pages = len(reader.pages)
|
||||
|
||||
# Extract text from all pages
|
||||
text_parts = []
|
||||
for page_num, page in enumerate(reader.pages, 1):
|
||||
page_text = page.extract_text()
|
||||
if page_text.strip():
|
||||
text_parts.append(f"--- Page {page_num} ---\n{page_text}")
|
||||
|
||||
if not text_parts:
|
||||
return ToolResult.success({
|
||||
"content": f"[PDF file with {total_pages} pages, but no text content could be extracted]",
|
||||
"total_pages": total_pages,
|
||||
"message": "PDF may contain only images or be encrypted"
|
||||
})
|
||||
|
||||
# Merge all text
|
||||
full_content = "\n\n".join(text_parts)
|
||||
all_lines = full_content.split('\n')
|
||||
total_lines = len(all_lines)
|
||||
|
||||
# Apply offset and limit (same logic as text files)
|
||||
start_line = 0
|
||||
if offset is not None:
|
||||
start_line = max(0, offset - 1)
|
||||
if start_line >= total_lines:
|
||||
return ToolResult.fail(
|
||||
f"Error: Offset {offset} is beyond end of content ({total_lines} lines total)"
|
||||
)
|
||||
|
||||
start_line_display = start_line + 1
|
||||
|
||||
selected_content = full_content
|
||||
user_limited_lines = None
|
||||
if limit is not None:
|
||||
end_line = min(start_line + limit, total_lines)
|
||||
selected_content = '\n'.join(all_lines[start_line:end_line])
|
||||
user_limited_lines = end_line - start_line
|
||||
elif offset is not None:
|
||||
selected_content = '\n'.join(all_lines[start_line:])
|
||||
|
||||
# Apply truncation
|
||||
truncation = truncate_head(selected_content)
|
||||
|
||||
output_text = ""
|
||||
details = {}
|
||||
|
||||
if truncation.truncated:
|
||||
end_line_display = start_line_display + truncation.output_lines - 1
|
||||
next_offset = end_line_display + 1
|
||||
|
||||
output_text = truncation.content
|
||||
|
||||
if truncation.truncated_by == "lines":
|
||||
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
|
||||
else:
|
||||
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Use offset={next_offset} to continue.]"
|
||||
|
||||
details["truncation"] = truncation.to_dict()
|
||||
elif user_limited_lines is not None and start_line + user_limited_lines < total_lines:
|
||||
remaining = total_lines - (start_line + user_limited_lines)
|
||||
next_offset = start_line + user_limited_lines + 1
|
||||
|
||||
output_text = truncation.content
|
||||
output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
|
||||
else:
|
||||
output_text = truncation.content
|
||||
|
||||
result = {
|
||||
"content": output_text,
|
||||
"total_pages": total_pages,
|
||||
"total_lines": total_lines,
|
||||
"start_line": start_line_display,
|
||||
"output_lines": truncation.output_lines
|
||||
}
|
||||
|
||||
if details:
|
||||
result["details"] = details
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error reading PDF file: {str(e)}")
|
||||
3
agent/tools/terminal/__init__.py
Normal file
3
agent/tools/terminal/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .terminal import Terminal
|
||||
|
||||
__all__ = ['Terminal']
|
||||
100
agent/tools/terminal/terminal.py
Normal file
100
agent/tools/terminal/terminal.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class Terminal(BaseTool):
|
||||
name: str = "terminal"
|
||||
description: str = "A tool to run terminal commands on the local system"
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": f"The terminal command to execute which should be valid in {platform.system()} platform"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
config: dict = {}
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or {}
|
||||
# Set of dangerous commands that should be blocked
|
||||
self.command_ban_set = {"halt", "poweroff", "shutdown", "reboot", "rm", "kill",
|
||||
"exit", "sudo", "su", "userdel", "groupdel", "logout", "alias"}
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute a terminal command safely.
|
||||
|
||||
:param args: Dictionary containing the command to execute
|
||||
:return: Result of the command execution
|
||||
"""
|
||||
command = args.get("command", "").strip()
|
||||
|
||||
# Check if the command is safe to execute
|
||||
if not self._is_safe_command(command):
|
||||
return ToolResult.fail(result=f"Command '{command}' is not allowed for security reasons.")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
check=True, # Raise exception on non-zero return code
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=self.config.get("timeout", 30)
|
||||
)
|
||||
|
||||
return ToolResult.success({
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"return_code": result.returncode,
|
||||
"command": command
|
||||
})
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Preserve the original error handling for CalledProcessError
|
||||
return ToolResult.fail({
|
||||
"stdout": e.stdout,
|
||||
"stderr": e.stderr,
|
||||
"return_code": e.returncode,
|
||||
"command": command
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
return ToolResult.fail(result=f"Command timed out after {self.config.get('timeout', 20)} seconds.")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(result=f"Error executing command: {str(e)}")
|
||||
|
||||
def _is_safe_command(self, command: str) -> bool:
|
||||
"""
|
||||
Check if a command is safe to execute.
|
||||
|
||||
:param command: The command to check
|
||||
:return: True if the command is safe, False otherwise
|
||||
"""
|
||||
# Split the command to get the base command
|
||||
cmd_parts = command.split()
|
||||
if not cmd_parts:
|
||||
return False
|
||||
|
||||
base_cmd = cmd_parts[0].lower()
|
||||
|
||||
# Check if the base command is in the ban list
|
||||
if base_cmd in self.command_ban_set:
|
||||
return False
|
||||
|
||||
# Check for sudo/su commands
|
||||
if any(banned in command.lower() for banned in ["sudo ", "su -"]):
|
||||
return False
|
||||
|
||||
# Check for rm -rf or similar dangerous patterns
|
||||
if "rm" in base_cmd and ("-rf" in command or "-r" in command or "-f" in command):
|
||||
return False
|
||||
|
||||
# Additional security checks can be added here
|
||||
|
||||
return True
|
||||
208
agent/tools/tool_manager.py
Normal file
208
agent/tools/tool_manager.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Type
|
||||
from agent.tools.base_tool import BaseTool
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""
|
||||
Tool manager for managing tools.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton pattern to ensure only one instance of ToolManager exists."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ToolManager, cls).__new__(cls)
|
||||
cls._instance.tool_classes = {} # Store tool classes instead of instances
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Initialize only once
|
||||
if not hasattr(self, 'tool_classes'):
|
||||
self.tool_classes = {} # Dictionary to store tool classes
|
||||
|
||||
def load_tools(self, tools_dir: str = "", config_dict=None):
|
||||
"""
|
||||
Load tools from both directory and configuration.
|
||||
|
||||
:param tools_dir: Directory to scan for tool modules
|
||||
"""
|
||||
if tools_dir:
|
||||
self._load_tools_from_directory(tools_dir)
|
||||
self._configure_tools_from_config()
|
||||
else:
|
||||
self._load_tools_from_init()
|
||||
self._configure_tools_from_config(config_dict)
|
||||
|
||||
def _load_tools_from_init(self) -> bool:
|
||||
"""
|
||||
Load tool classes from tools.__init__.__all__
|
||||
|
||||
:return: True if tools were loaded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Try to import the tools package
|
||||
tools_package = importlib.import_module("agent.tools")
|
||||
|
||||
# Check if __all__ is defined
|
||||
if hasattr(tools_package, "__all__"):
|
||||
tool_classes = tools_package.__all__
|
||||
|
||||
# Import each tool class directly from the tools package
|
||||
for class_name in tool_classes:
|
||||
try:
|
||||
# Skip base classes
|
||||
if class_name in ["BaseTool", "ToolManager"]:
|
||||
continue
|
||||
|
||||
# Get the class directly from the tools package
|
||||
if hasattr(tools_package, class_name):
|
||||
cls = getattr(tools_package, class_name)
|
||||
|
||||
if (
|
||||
isinstance(cls, type)
|
||||
and issubclass(cls, BaseTool)
|
||||
and cls != BaseTool
|
||||
):
|
||||
try:
|
||||
# Create a temporary instance to get the name
|
||||
temp_instance = cls()
|
||||
tool_name = temp_instance.name
|
||||
# Store the class, not the instance
|
||||
self.tool_classes[tool_name] = cls
|
||||
logger.debug(f"Loaded tool: {tool_name} from class {class_name}")
|
||||
except ImportError as e:
|
||||
# Ignore browser_use dependency missing errors
|
||||
if "browser_use" in str(e):
|
||||
pass
|
||||
else:
|
||||
logger.error(f"Error initializing tool class {cls.__name__}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing tool class {cls.__name__}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing class {class_name}: {e}")
|
||||
|
||||
return len(self.tool_classes) > 0
|
||||
return False
|
||||
except ImportError:
|
||||
logger.warning("Could not import agent.tools package")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading tools from __init__.__all__: {e}")
|
||||
return False
|
||||
|
||||
def _load_tools_from_directory(self, tools_dir: str):
|
||||
"""Dynamically load tool classes from directory"""
|
||||
tools_path = Path(tools_dir)
|
||||
|
||||
# Traverse all .py files
|
||||
for py_file in tools_path.rglob("*.py"):
|
||||
# Skip initialization files and base tool files
|
||||
if py_file.name in ["__init__.py", "base_tool.py", "tool_manager.py"]:
|
||||
continue
|
||||
|
||||
# Get module name
|
||||
module_name = py_file.stem
|
||||
|
||||
try:
|
||||
# Load module directly from file
|
||||
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find tool classes in the module
|
||||
for attr_name in dir(module):
|
||||
cls = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(cls, type)
|
||||
and issubclass(cls, BaseTool)
|
||||
and cls != BaseTool
|
||||
):
|
||||
try:
|
||||
# Create a temporary instance to get the name
|
||||
temp_instance = cls()
|
||||
tool_name = temp_instance.name
|
||||
# Store the class, not the instance
|
||||
self.tool_classes[tool_name] = cls
|
||||
except ImportError as e:
|
||||
# Ignore browser_use dependency missing errors
|
||||
if "browser_use" in str(e):
|
||||
pass
|
||||
else:
|
||||
print(f"Error initializing tool class {cls.__name__}: {e}")
|
||||
except Exception as e:
|
||||
print(f"Error initializing tool class {cls.__name__}: {e}")
|
||||
except Exception as e:
|
||||
print(f"Error importing module {py_file}: {e}")
|
||||
|
||||
def _configure_tools_from_config(self, config_dict=None):
|
||||
"""Configure tool classes based on configuration file"""
|
||||
try:
|
||||
# Get tools configuration
|
||||
tools_config = config_dict or config().get("tools", {})
|
||||
|
||||
# Record tools that are configured but not loaded
|
||||
missing_tools = []
|
||||
|
||||
# Store configurations for later use when instantiating
|
||||
self.tool_configs = tools_config
|
||||
|
||||
# Check which configured tools are missing
|
||||
for tool_name in tools_config:
|
||||
if tool_name not in self.tool_classes:
|
||||
missing_tools.append(tool_name)
|
||||
|
||||
# If there are missing tools, record warnings
|
||||
if missing_tools:
|
||||
for tool_name in missing_tools:
|
||||
if tool_name == "browser":
|
||||
logger.error(
|
||||
"Browser tool is configured but could not be loaded. "
|
||||
"Please install the required dependency with: "
|
||||
"pip install browser-use>=0.1.40 or pip install agentmesh-sdk[full]"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Tool '{tool_name}' is configured but could not be loaded.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error configuring tools from config: {e}")
|
||||
|
||||
def create_tool(self, name: str) -> BaseTool:
|
||||
"""
|
||||
Get a new instance of a tool by name.
|
||||
|
||||
:param name: The name of the tool to get.
|
||||
:return: A new instance of the tool or None if not found.
|
||||
"""
|
||||
tool_class = self.tool_classes.get(name)
|
||||
if tool_class:
|
||||
# Create a new instance
|
||||
tool_instance = tool_class()
|
||||
|
||||
# Apply configuration if available
|
||||
if hasattr(self, 'tool_configs') and name in self.tool_configs:
|
||||
tool_instance.config = self.tool_configs[name]
|
||||
|
||||
return tool_instance
|
||||
return None
|
||||
|
||||
def list_tools(self) -> dict:
|
||||
"""
|
||||
Get information about all loaded tools.
|
||||
|
||||
:return: A dictionary with tool information.
|
||||
"""
|
||||
result = {}
|
||||
for name, tool_class in self.tool_classes.items():
|
||||
# Create a temporary instance to get schema
|
||||
temp_instance = tool_class()
|
||||
result[name] = {
|
||||
"description": temp_instance.description,
|
||||
"parameters": temp_instance.get_json_schema()
|
||||
}
|
||||
return result
|
||||
40
agent/tools/utils/__init__.py
Normal file
40
agent/tools/utils/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from .truncate import (
|
||||
truncate_head,
|
||||
truncate_tail,
|
||||
truncate_line,
|
||||
format_size,
|
||||
TruncationResult,
|
||||
DEFAULT_MAX_LINES,
|
||||
DEFAULT_MAX_BYTES,
|
||||
GREP_MAX_LINE_LENGTH
|
||||
)
|
||||
|
||||
from .diff import (
|
||||
strip_bom,
|
||||
detect_line_ending,
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string,
|
||||
FuzzyMatchResult
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'truncate_head',
|
||||
'truncate_tail',
|
||||
'truncate_line',
|
||||
'format_size',
|
||||
'TruncationResult',
|
||||
'DEFAULT_MAX_LINES',
|
||||
'DEFAULT_MAX_BYTES',
|
||||
'GREP_MAX_LINE_LENGTH',
|
||||
'strip_bom',
|
||||
'detect_line_ending',
|
||||
'normalize_to_lf',
|
||||
'restore_line_endings',
|
||||
'normalize_for_fuzzy_match',
|
||||
'fuzzy_find_text',
|
||||
'generate_diff_string',
|
||||
'FuzzyMatchResult'
|
||||
]
|
||||
167
agent/tools/utils/diff.py
Normal file
167
agent/tools/utils/diff.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Diff tools for file editing
|
||||
Provides fuzzy matching and diff generation functionality
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def strip_bom(text: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Remove BOM (Byte Order Mark)
|
||||
|
||||
:param text: Original text
|
||||
:return: (BOM, text after removing BOM)
|
||||
"""
|
||||
if text.startswith('\ufeff'):
|
||||
return '\ufeff', text[1:]
|
||||
return '', text
|
||||
|
||||
|
||||
def detect_line_ending(text: str) -> str:
|
||||
"""
|
||||
Detect line ending type
|
||||
|
||||
:param text: Text content
|
||||
:return: Line ending type ('\r\n' or '\n')
|
||||
"""
|
||||
if '\r\n' in text:
|
||||
return '\r\n'
|
||||
return '\n'
|
||||
|
||||
|
||||
def normalize_to_lf(text: str) -> str:
|
||||
"""
|
||||
Normalize all line endings to LF (\n)
|
||||
|
||||
:param text: Original text
|
||||
:return: Normalized text
|
||||
"""
|
||||
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
|
||||
def restore_line_endings(text: str, original_ending: str) -> str:
|
||||
"""
|
||||
Restore original line endings
|
||||
|
||||
:param text: LF normalized text
|
||||
:param original_ending: Original line ending
|
||||
:return: Text with restored line endings
|
||||
"""
|
||||
if original_ending == '\r\n':
|
||||
return text.replace('\n', '\r\n')
|
||||
return text
|
||||
|
||||
|
||||
def normalize_for_fuzzy_match(text: str) -> str:
|
||||
"""
|
||||
Normalize text for fuzzy matching
|
||||
Remove excess whitespace but preserve basic structure
|
||||
|
||||
:param text: Original text
|
||||
:return: Normalized text
|
||||
"""
|
||||
# Compress multiple spaces to one
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
# Remove trailing spaces
|
||||
text = re.sub(r' +\n', '\n', text)
|
||||
# Remove leading spaces (but preserve indentation structure, only remove excess)
|
||||
lines = text.split('\n')
|
||||
normalized_lines = []
|
||||
for line in lines:
|
||||
# Preserve indentation but normalize to multiples of single spaces
|
||||
stripped = line.lstrip()
|
||||
if stripped:
|
||||
indent_count = len(line) - len(stripped)
|
||||
# Normalize indentation (convert tabs to spaces)
|
||||
normalized_indent = ' ' * indent_count
|
||||
normalized_lines.append(normalized_indent + stripped)
|
||||
else:
|
||||
normalized_lines.append('')
|
||||
return '\n'.join(normalized_lines)
|
||||
|
||||
|
||||
class FuzzyMatchResult:
|
||||
"""Fuzzy match result"""
|
||||
|
||||
def __init__(self, found: bool, index: int = -1, match_length: int = 0, content_for_replacement: str = ""):
|
||||
self.found = found
|
||||
self.index = index
|
||||
self.match_length = match_length
|
||||
self.content_for_replacement = content_for_replacement
|
||||
|
||||
|
||||
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
"""
|
||||
Find text in content, try exact match first, then fuzzy match
|
||||
|
||||
:param content: Content to search in
|
||||
:param old_text: Text to find
|
||||
:return: Match result
|
||||
"""
|
||||
# First try exact match
|
||||
index = content.find(old_text)
|
||||
if index != -1:
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=index,
|
||||
match_length=len(old_text),
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Try fuzzy match
|
||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
||||
|
||||
index = fuzzy_content.find(fuzzy_old_text)
|
||||
if index != -1:
|
||||
# Fuzzy match successful, use normalized content for replacement
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=index,
|
||||
match_length=len(fuzzy_old_text),
|
||||
content_for_replacement=fuzzy_content
|
||||
)
|
||||
|
||||
# Not found
|
||||
return FuzzyMatchResult(found=False)
|
||||
|
||||
|
||||
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
||||
"""
|
||||
Generate unified diff string
|
||||
|
||||
:param old_content: Old content
|
||||
:param new_content: New content
|
||||
:return: Dictionary containing diff and first changed line number
|
||||
"""
|
||||
old_lines = old_content.split('\n')
|
||||
new_lines = new_content.split('\n')
|
||||
|
||||
# Generate unified diff
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
old_lines,
|
||||
new_lines,
|
||||
lineterm='',
|
||||
fromfile='original',
|
||||
tofile='modified'
|
||||
))
|
||||
|
||||
# Find first changed line number
|
||||
first_changed_line = None
|
||||
for line in diff_lines:
|
||||
if line.startswith('@@'):
|
||||
# Parse @@ -1,3 +1,3 @@ format
|
||||
match = re.search(r'@@ -\d+,?\d* \+(\d+)', line)
|
||||
if match:
|
||||
first_changed_line = int(match.group(1))
|
||||
break
|
||||
|
||||
diff_string = '\n'.join(diff_lines)
|
||||
|
||||
return {
|
||||
'diff': diff_string,
|
||||
'first_changed_line': first_changed_line
|
||||
}
|
||||
292
agent/tools/utils/truncate.py
Normal file
292
agent/tools/utils/truncate.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Shared truncation utilities for tool outputs.
|
||||
|
||||
Truncation is based on two independent limits - whichever is hit first wins:
|
||||
- Line limit (default: 2000 lines)
|
||||
- Byte limit (default: 50KB)
|
||||
|
||||
Never returns partial lines (except bash tail truncation edge case).
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Literal
|
||||
|
||||
|
||||
DEFAULT_MAX_LINES = 2000
|
||||
DEFAULT_MAX_BYTES = 50 * 1024 # 50KB
|
||||
GREP_MAX_LINE_LENGTH = 500 # Max chars per grep match line
|
||||
|
||||
|
||||
class TruncationResult:
|
||||
"""Truncation result"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
truncated: bool,
|
||||
truncated_by: Optional[Literal["lines", "bytes"]],
|
||||
total_lines: int,
|
||||
total_bytes: int,
|
||||
output_lines: int,
|
||||
output_bytes: int,
|
||||
last_line_partial: bool = False,
|
||||
first_line_exceeds_limit: bool = False,
|
||||
max_lines: int = DEFAULT_MAX_LINES,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES
|
||||
):
|
||||
self.content = content
|
||||
self.truncated = truncated
|
||||
self.truncated_by = truncated_by
|
||||
self.total_lines = total_lines
|
||||
self.total_bytes = total_bytes
|
||||
self.output_lines = output_lines
|
||||
self.output_bytes = output_bytes
|
||||
self.last_line_partial = last_line_partial
|
||||
self.first_line_exceeds_limit = first_line_exceeds_limit
|
||||
self.max_lines = max_lines
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
"content": self.content,
|
||||
"truncated": self.truncated,
|
||||
"truncated_by": self.truncated_by,
|
||||
"total_lines": self.total_lines,
|
||||
"total_bytes": self.total_bytes,
|
||||
"output_lines": self.output_lines,
|
||||
"output_bytes": self.output_bytes,
|
||||
"last_line_partial": self.last_line_partial,
|
||||
"first_line_exceeds_limit": self.first_line_exceeds_limit,
|
||||
"max_lines": self.max_lines,
|
||||
"max_bytes": self.max_bytes
|
||||
}
|
||||
|
||||
|
||||
def format_size(bytes_count: int) -> str:
|
||||
"""Format bytes as human-readable size"""
|
||||
if bytes_count < 1024:
|
||||
return f"{bytes_count}B"
|
||||
elif bytes_count < 1024 * 1024:
|
||||
return f"{bytes_count / 1024:.1f}KB"
|
||||
else:
|
||||
return f"{bytes_count / (1024 * 1024):.1f}MB"
|
||||
|
||||
|
||||
def truncate_head(content: str, max_lines: Optional[int] = None, max_bytes: Optional[int] = None) -> TruncationResult:
|
||||
"""
|
||||
Truncate content from the head (keep first N lines/bytes).
|
||||
Suitable for file reads where you want to see the beginning.
|
||||
|
||||
Never returns partial lines. If first line exceeds byte limit,
|
||||
returns empty content with first_line_exceeds_limit=True.
|
||||
|
||||
:param content: Content to truncate
|
||||
:param max_lines: Maximum number of lines (default: 2000)
|
||||
:param max_bytes: Maximum number of bytes (default: 50KB)
|
||||
:return: Truncation result
|
||||
"""
|
||||
if max_lines is None:
|
||||
max_lines = DEFAULT_MAX_LINES
|
||||
if max_bytes is None:
|
||||
max_bytes = DEFAULT_MAX_BYTES
|
||||
|
||||
total_bytes = len(content.encode('utf-8'))
|
||||
lines = content.split('\n')
|
||||
total_lines = len(lines)
|
||||
|
||||
# Check if no truncation is needed
|
||||
if total_lines <= max_lines and total_bytes <= max_bytes:
|
||||
return TruncationResult(
|
||||
content=content,
|
||||
truncated=False,
|
||||
truncated_by=None,
|
||||
total_lines=total_lines,
|
||||
total_bytes=total_bytes,
|
||||
output_lines=total_lines,
|
||||
output_bytes=total_bytes,
|
||||
last_line_partial=False,
|
||||
first_line_exceeds_limit=False,
|
||||
max_lines=max_lines,
|
||||
max_bytes=max_bytes
|
||||
)
|
||||
|
||||
# Check if first line alone exceeds byte limit
|
||||
first_line_bytes = len(lines[0].encode('utf-8'))
|
||||
if first_line_bytes > max_bytes:
|
||||
return TruncationResult(
|
||||
content="",
|
||||
truncated=True,
|
||||
truncated_by="bytes",
|
||||
total_lines=total_lines,
|
||||
total_bytes=total_bytes,
|
||||
output_lines=0,
|
||||
output_bytes=0,
|
||||
last_line_partial=False,
|
||||
first_line_exceeds_limit=True,
|
||||
max_lines=max_lines,
|
||||
max_bytes=max_bytes
|
||||
)
|
||||
|
||||
# Collect complete lines that fit
|
||||
output_lines_arr = []
|
||||
output_bytes_count = 0
|
||||
truncated_by = "lines"
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if i >= max_lines:
|
||||
break
|
||||
|
||||
# Calculate line bytes (add 1 for newline if not first line)
|
||||
line_bytes = len(line.encode('utf-8')) + (1 if i > 0 else 0)
|
||||
|
||||
if output_bytes_count + line_bytes > max_bytes:
|
||||
truncated_by = "bytes"
|
||||
break
|
||||
|
||||
output_lines_arr.append(line)
|
||||
output_bytes_count += line_bytes
|
||||
|
||||
# If exited due to line limit
|
||||
if len(output_lines_arr) >= max_lines and output_bytes_count <= max_bytes:
|
||||
truncated_by = "lines"
|
||||
|
||||
output_content = '\n'.join(output_lines_arr)
|
||||
final_output_bytes = len(output_content.encode('utf-8'))
|
||||
|
||||
return TruncationResult(
|
||||
content=output_content,
|
||||
truncated=True,
|
||||
truncated_by=truncated_by,
|
||||
total_lines=total_lines,
|
||||
total_bytes=total_bytes,
|
||||
output_lines=len(output_lines_arr),
|
||||
output_bytes=final_output_bytes,
|
||||
last_line_partial=False,
|
||||
first_line_exceeds_limit=False,
|
||||
max_lines=max_lines,
|
||||
max_bytes=max_bytes
|
||||
)
|
||||
|
||||
|
||||
def truncate_tail(content: str, max_lines: Optional[int] = None, max_bytes: Optional[int] = None) -> TruncationResult:
|
||||
"""
|
||||
Truncate content from tail (keep last N lines/bytes).
|
||||
Suitable for bash output where you want to see the ending content (errors, final results).
|
||||
|
||||
If the last line of original content exceeds byte limit, may return partial first line.
|
||||
|
||||
:param content: Content to truncate
|
||||
:param max_lines: Maximum lines (default: 2000)
|
||||
:param max_bytes: Maximum bytes (default: 50KB)
|
||||
:return: Truncation result
|
||||
"""
|
||||
if max_lines is None:
|
||||
max_lines = DEFAULT_MAX_LINES
|
||||
if max_bytes is None:
|
||||
max_bytes = DEFAULT_MAX_BYTES
|
||||
|
||||
total_bytes = len(content.encode('utf-8'))
|
||||
lines = content.split('\n')
|
||||
total_lines = len(lines)
|
||||
|
||||
# Check if no truncation is needed
|
||||
if total_lines <= max_lines and total_bytes <= max_bytes:
|
||||
return TruncationResult(
|
||||
content=content,
|
||||
truncated=False,
|
||||
truncated_by=None,
|
||||
total_lines=total_lines,
|
||||
total_bytes=total_bytes,
|
||||
output_lines=total_lines,
|
||||
output_bytes=total_bytes,
|
||||
last_line_partial=False,
|
||||
first_line_exceeds_limit=False,
|
||||
max_lines=max_lines,
|
||||
max_bytes=max_bytes
|
||||
)
|
||||
|
||||
# Work backwards from the end
|
||||
output_lines_arr = []
|
||||
output_bytes_count = 0
|
||||
truncated_by = "lines"
|
||||
last_line_partial = False
|
||||
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if len(output_lines_arr) >= max_lines:
|
||||
break
|
||||
|
||||
line = lines[i]
|
||||
# Calculate line bytes (add newline if not the first added line)
|
||||
line_bytes = len(line.encode('utf-8')) + (1 if len(output_lines_arr) > 0 else 0)
|
||||
|
||||
if output_bytes_count + line_bytes > max_bytes:
|
||||
truncated_by = "bytes"
|
||||
# Edge case: if we haven't added any lines yet and this line exceeds maxBytes,
|
||||
# take the end portion of this line
|
||||
if len(output_lines_arr) == 0:
|
||||
truncated_line = _truncate_string_to_bytes_from_end(line, max_bytes)
|
||||
output_lines_arr.insert(0, truncated_line)
|
||||
output_bytes_count = len(truncated_line.encode('utf-8'))
|
||||
last_line_partial = True
|
||||
break
|
||||
|
||||
output_lines_arr.insert(0, line)
|
||||
output_bytes_count += line_bytes
|
||||
|
||||
# If exited due to line limit
|
||||
if len(output_lines_arr) >= max_lines and output_bytes_count <= max_bytes:
|
||||
truncated_by = "lines"
|
||||
|
||||
output_content = '\n'.join(output_lines_arr)
|
||||
final_output_bytes = len(output_content.encode('utf-8'))
|
||||
|
||||
return TruncationResult(
|
||||
content=output_content,
|
||||
truncated=True,
|
||||
truncated_by=truncated_by,
|
||||
total_lines=total_lines,
|
||||
total_bytes=total_bytes,
|
||||
output_lines=len(output_lines_arr),
|
||||
output_bytes=final_output_bytes,
|
||||
last_line_partial=last_line_partial,
|
||||
first_line_exceeds_limit=False,
|
||||
max_lines=max_lines,
|
||||
max_bytes=max_bytes
|
||||
)
|
||||
|
||||
|
||||
def _truncate_string_to_bytes_from_end(text: str, max_bytes: int) -> str:
|
||||
"""
|
||||
Truncate string to fit byte limit (from end).
|
||||
Properly handles multi-byte UTF-8 characters.
|
||||
|
||||
:param text: String to truncate
|
||||
:param max_bytes: Maximum bytes
|
||||
:return: Truncated string
|
||||
"""
|
||||
encoded = text.encode('utf-8')
|
||||
if len(encoded) <= max_bytes:
|
||||
return text
|
||||
|
||||
# Start from end, skip back maxBytes
|
||||
start = len(encoded) - max_bytes
|
||||
|
||||
# Find valid UTF-8 boundary (character start)
|
||||
while start < len(encoded) and (encoded[start] & 0xC0) == 0x80:
|
||||
start += 1
|
||||
|
||||
return encoded[start:].decode('utf-8', errors='ignore')
|
||||
|
||||
|
||||
def truncate_line(line: str, max_chars: int = GREP_MAX_LINE_LENGTH) -> tuple[str, bool]:
|
||||
"""
|
||||
Truncate single line to max characters, add [truncated] suffix.
|
||||
Used for grep match lines.
|
||||
|
||||
:param line: Line to truncate
|
||||
:param max_chars: Maximum characters
|
||||
:return: (truncated text, whether truncated)
|
||||
"""
|
||||
if len(line) <= max_chars:
|
||||
return line, False
|
||||
return f"{line[:max_chars]}... [truncated]", True
|
||||
3
agent/tools/write/__init__.py
Normal file
3
agent/tools/write/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .write import Write
|
||||
|
||||
__all__ = ['Write']
|
||||
91
agent/tools/write/write.py
Normal file
91
agent/tools/write/write.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Write tool - Write file content
|
||||
Creates or overwrites files, automatically creates parent directories
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class Write(BaseTool):
|
||||
"""Tool for writing file content"""
|
||||
|
||||
name: str = "write"
|
||||
description: str = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to write (relative or absolute)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
}
|
||||
|
||||
def __init__(self, config: dict = None):
|
||||
self.config = config or {}
|
||||
self.cwd = self.config.get("cwd", os.getcwd())
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute file write operation
|
||||
|
||||
:param args: Contains file path and content
|
||||
:return: Operation result
|
||||
"""
|
||||
path = args.get("path", "").strip()
|
||||
content = args.get("content", "")
|
||||
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
try:
|
||||
# Create parent directory (if needed)
|
||||
parent_dir = os.path.dirname(absolute_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
with open(absolute_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
# Get bytes written
|
||||
bytes_written = len(content.encode('utf-8'))
|
||||
|
||||
result = {
|
||||
"message": f"Successfully wrote {bytes_written} bytes to {path}",
|
||||
"path": path,
|
||||
"bytes_written": bytes_written
|
||||
}
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
except PermissionError:
|
||||
return ToolResult.fail(f"Error: Permission denied writing to {path}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error writing file: {str(e)}")
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""
|
||||
Resolve path to absolute path
|
||||
|
||||
:param path: Relative or absolute path
|
||||
:return: Absolute path
|
||||
"""
|
||||
# Expand ~ to user home directory
|
||||
path = os.path.expanduser(path)
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
Reference in New Issue
Block a user