feat: personal ai agent framework

This commit is contained in:
saboteur7
2026-01-30 09:53:46 +08:00
parent 25cf6823d0
commit bb850bb6c5
62 changed files with 7675 additions and 275 deletions

101
agent/tools/__init__.py Normal file
View 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
View 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

View File

@@ -0,0 +1,3 @@
from .bash import Bash
__all__ = ['Bash']

187
agent/tools/bash/bash.py Normal file
View 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

View 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"

View 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}")

View 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

View 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", "")
})

View 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))

View File

@@ -0,0 +1,3 @@
from .edit import Edit
__all__ = ['Edit']

164
agent/tools/edit/edit.py Normal file
View 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))

View File

@@ -0,0 +1,3 @@
from .file_save import FileSave
__all__ = ['FileSave']

View 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

View File

@@ -0,0 +1,3 @@
from .find import Find
__all__ = ['Find']

177
agent/tools/find/find.py Normal file
View 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

View 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)

View File

@@ -0,0 +1,3 @@
from .grep import Grep
__all__ = ['Grep']

248
agent/tools/grep/grep.py Normal file
View 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))

View File

@@ -0,0 +1,3 @@
from .ls import Ls
__all__ = ['Ls']

125
agent/tools/ls/ls.py Normal file
View 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))

View 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']

View 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)}")

View 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)}")

View File

@@ -0,0 +1,3 @@
from .read import Read
__all__ = ['Read']

336
agent/tools/read/read.py Normal file
View 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)}")

View File

@@ -0,0 +1,3 @@
from .terminal import Terminal
__all__ = ['Terminal']

View 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
View 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

View 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
View 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
}

View 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

View File

@@ -0,0 +1,3 @@
from .write import Write
__all__ = ['Write']

View 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))