ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Tools module - All tool implementations
|
||||
"""
|
||||
|
||||
from .base import BaseTool, ToolResult
|
||||
from .bash_tool import BashTool
|
||||
from .bash_output_tool import BashOutputTool
|
||||
from .kill_bash_tool import KillBashTool
|
||||
from .read_tool import ReadTool
|
||||
from .write_tool import WriteTool
|
||||
from .edit_tool import EditTool
|
||||
from .multi_edit_tool import MultiEditTool
|
||||
from .grep_tool import GrepTool
|
||||
from .glob_tool import GlobTool
|
||||
from .ls_tool import LSTool
|
||||
from .todo_write_tool import TodoWriteTool
|
||||
from .exit_plan_mode_tool import ExitPlanModeTool
|
||||
from .notebook_edit_tool import NotebookEditTool
|
||||
from .web_fetch_tool import WebFetchTool
|
||||
from .web_search_tool import WebSearchTool
|
||||
from .task_tool import TaskTool
|
||||
from .shell_session import ShellSession
|
||||
|
||||
|
||||
__all__ = [
|
||||
'BaseTool',
|
||||
'ToolResult',
|
||||
'BashTool',
|
||||
'BashOutputTool',
|
||||
'KillBashTool',
|
||||
'ReadTool',
|
||||
'WriteTool',
|
||||
'EditTool',
|
||||
'MultiEditTool',
|
||||
'GrepTool',
|
||||
'GlobTool',
|
||||
'LSTool',
|
||||
'TodoWriteTool',
|
||||
'ExitPlanModeTool',
|
||||
'NotebookEditTool',
|
||||
'WebFetchTool',
|
||||
'WebSearchTool',
|
||||
'TaskTool',
|
||||
'ShellSession'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Base classes for tool implementation
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""Result from a tool execution"""
|
||||
success: bool
|
||||
data: Dict[str, Any]
|
||||
error: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
result = self.data.copy()
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
if self.metadata:
|
||||
result["_metadata"] = self.metadata
|
||||
return result
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
"""Base class for all tools"""
|
||||
|
||||
def __init__(self, system_state: 'SystemState'):
|
||||
self.state = system_state
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Tool name"""
|
||||
pass
|
||||
|
||||
def execute(self, params: Dict[str, Any]) -> ToolResult:
|
||||
"""
|
||||
Execute the tool with given parameters
|
||||
|
||||
Args:
|
||||
params: Tool input parameters
|
||||
|
||||
Returns:
|
||||
ToolResult with data and metadata
|
||||
"""
|
||||
# Track tool call
|
||||
self.state.tool_call_counts[self.name] = self.state.tool_call_counts.get(self.name, 0) + 1
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
call_number = self.state.tool_call_counts[self.name]
|
||||
|
||||
try:
|
||||
# Call implementation
|
||||
data = self._execute_impl(params)
|
||||
|
||||
# Add metadata
|
||||
metadata = {
|
||||
"tool": self.name,
|
||||
"call_number": call_number,
|
||||
"timestamp": timestamp
|
||||
}
|
||||
|
||||
return ToolResult(success=True, data=data, metadata=metadata)
|
||||
|
||||
except Exception as e:
|
||||
error_data = {
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"tool": self.name,
|
||||
"input": params
|
||||
}
|
||||
|
||||
metadata = {
|
||||
"tool": self.name,
|
||||
"call_number": call_number,
|
||||
"timestamp": timestamp
|
||||
}
|
||||
|
||||
return ToolResult(success=False, data=error_data, error=str(e), metadata=metadata)
|
||||
|
||||
@abstractmethod
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Implement tool-specific logic
|
||||
|
||||
Args:
|
||||
params: Tool input parameters
|
||||
|
||||
Returns:
|
||||
Dictionary with tool results
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
BashOutput tool - Retrieve output from background bash jobs
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
from .shell_session import get_background_log_path
|
||||
|
||||
|
||||
class BashOutputTool(BaseTool):
|
||||
"""Retrieves output from running or completed background bash shells"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "BashOutput"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get output from background bash job
|
||||
|
||||
- Retrieves output from a running or completed background bash shell
|
||||
- Takes a bash_id parameter identifying the shell
|
||||
- Always returns only new output since the last check
|
||||
- Supports optional regex filtering
|
||||
"""
|
||||
bash_id = params["bash_id"]
|
||||
filter_pattern = params.get("filter")
|
||||
|
||||
log_file = get_background_log_path(bash_id)
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
return {"error": f"Bash job not found (no output log) for bash_id: {bash_id}"}
|
||||
|
||||
try:
|
||||
# Return only what has been appended since the last check, as the
|
||||
# tool description promises. The offset is per bash_id and lives in
|
||||
# SystemState so it survives across calls.
|
||||
previous_offset = self.state.bash_output_offsets.get(bash_id, 0)
|
||||
if os.path.getsize(log_file) < previous_offset:
|
||||
# Log was truncated or rotated — start over rather than
|
||||
# seeking past the end and returning nothing forever.
|
||||
previous_offset = 0
|
||||
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as f:
|
||||
f.seek(previous_offset)
|
||||
output = f.read()
|
||||
self.state.bash_output_offsets[bash_id] = f.tell()
|
||||
|
||||
if filter_pattern:
|
||||
# Filter lines matching pattern
|
||||
lines = output.split('\n')
|
||||
filtered_lines = [line for line in lines if re.search(filter_pattern, line)]
|
||||
output = '\n'.join(filtered_lines)
|
||||
|
||||
return {
|
||||
"bash_id": bash_id,
|
||||
"output": output,
|
||||
"output_size": len(output)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Error reading bash output: {str(e)}"}
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Bash tool - Command execution in persistent shell sessions
|
||||
"""
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class BashTool(BaseTool):
|
||||
"""Executes bash commands in persistent shell sessions"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Bash"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute bash command in persistent shell
|
||||
|
||||
- Commands execute in a persistent shell session
|
||||
- Working directory changes persist across commands
|
||||
- Environment variables persist
|
||||
- Supports background execution with run_in_background parameter
|
||||
- Output truncated if exceeds 30000 characters
|
||||
"""
|
||||
command = params["command"]
|
||||
timeout_ms = params.get("timeout")
|
||||
# None/<=0: treat like omit. Exact 0 used to become timeout=0s and drop all output.
|
||||
if timeout_ms is None or timeout_ms <= 0:
|
||||
timeout_ms = 120000
|
||||
timeout = timeout_ms / 1000 # Convert ms to seconds
|
||||
run_in_background = params.get("run_in_background", False)
|
||||
|
||||
# Get or create shell session
|
||||
shell_id = self.state.default_shell_id
|
||||
if shell_id not in self.state.shell_sessions:
|
||||
from .shell_session import ShellSession
|
||||
self.state.shell_sessions[shell_id] = ShellSession(
|
||||
session_id=shell_id,
|
||||
current_directory=self.state.current_directory
|
||||
)
|
||||
|
||||
session = self.state.shell_sessions[shell_id]
|
||||
|
||||
if run_in_background:
|
||||
# Start a separate native-shell process. ShellSession handles the
|
||||
# platform-specific invocation and log location.
|
||||
bg_id = f"bg_{int(time.time())}_{hashlib.md5(command.encode()).hexdigest()[:8]}"
|
||||
pid = session.start_background(command, bg_id)
|
||||
|
||||
return {
|
||||
"output": f"Background job started with ID: {bg_id}\nPID: {pid}",
|
||||
"exit_code": 0,
|
||||
"shell_id": shell_id,
|
||||
"background_job_id": bg_id
|
||||
}
|
||||
else:
|
||||
# Execute command synchronously
|
||||
output, exit_code = session.execute(command, timeout=timeout)
|
||||
|
||||
# Update system state directory
|
||||
self.state.current_directory = session.current_directory
|
||||
|
||||
# Truncate output if too long
|
||||
if len(output) > 30000:
|
||||
output = output[:30000] + f"\n... (output truncated, {len(output)} total characters)"
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
"exit_code": exit_code,
|
||||
"shell_id": shell_id,
|
||||
"working_directory": session.current_directory
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Edit tool - File editing with search and replace
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class EditTool(BaseTool):
|
||||
"""Performs exact string replacements in files"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Edit"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Edit file using search and replace
|
||||
|
||||
- You must use Read tool at least once before editing
|
||||
- Ensure you preserve exact indentation (tabs/spaces)
|
||||
- The edit will FAIL if old_string is not unique in the file
|
||||
- Use replace_all to change every instance of old_string
|
||||
"""
|
||||
file_path = Path(params["file_path"]).expanduser().resolve()
|
||||
old_string = params["old_string"]
|
||||
new_string = params["new_string"]
|
||||
replace_all = params.get("replace_all", False)
|
||||
|
||||
if not file_path.exists():
|
||||
return {"error": f"File not found: {file_path}"}
|
||||
|
||||
# Empty old_string matches between every character; replace_all would insert everywhere.
|
||||
if old_string == "":
|
||||
return {"error": "old_string cannot be empty"}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if old_string exists
|
||||
if old_string not in content:
|
||||
return {"error": f"String not found in file: {old_string[:100]}..."}
|
||||
|
||||
# Count occurrences
|
||||
occurrences = content.count(old_string)
|
||||
|
||||
# Check uniqueness if not replace_all
|
||||
if not replace_all and occurrences > 1:
|
||||
return {
|
||||
"error": f"String appears {occurrences} times in file. Use replace_all=true or provide more context to make it unique."
|
||||
}
|
||||
|
||||
# Perform replacement
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
replacements = occurrences
|
||||
else:
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
replacements = 1
|
||||
|
||||
# Write back
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
result = {
|
||||
"file_path": str(file_path),
|
||||
"replacements": replacements,
|
||||
"old_length": len(content),
|
||||
"new_length": len(new_content)
|
||||
}
|
||||
|
||||
# Check for lint errors
|
||||
lint_result = self._check_lint_errors(file_path)
|
||||
if lint_result:
|
||||
result["lint_check"] = lint_result
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Error editing file: {str(e)}"}
|
||||
|
||||
def _check_lint_errors(self, file_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check for lint errors after file modification"""
|
||||
suffix = file_path.suffix
|
||||
|
||||
try:
|
||||
if suffix == ".py":
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "py_compile", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"language": "python",
|
||||
"has_errors": True,
|
||||
"errors": result.stderr
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"language": "python",
|
||||
"has_errors": False,
|
||||
"message": "No syntax errors detected"
|
||||
}
|
||||
|
||||
elif suffix in [".js", ".jsx", ".ts", ".tsx"]:
|
||||
result = subprocess.run(
|
||||
["node", "--check", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"language": "javascript/typescript",
|
||||
"has_errors": True,
|
||||
"errors": result.stderr
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"language": "javascript/typescript",
|
||||
"has_errors": False,
|
||||
"message": "No syntax errors detected"
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "Lint check timed out"}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
ExitPlanMode tool - Exit plan mode after presenting plan
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class ExitPlanModeTool(BaseTool):
|
||||
"""Use this tool when you are in plan mode and ready to code"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ExitPlanMode"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Exit plan mode
|
||||
|
||||
- Use this tool when you are in plan mode and have finished presenting your plan
|
||||
- This will prompt the user to exit plan mode
|
||||
- IMPORTANT: Only use for tasks that require planning implementation steps for code writing
|
||||
"""
|
||||
plan = params["plan"]
|
||||
|
||||
return {
|
||||
"action": "exit_plan_mode",
|
||||
"plan": plan,
|
||||
"message": "Plan presented. Ready to implement."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Glob tool - Pure Python file pattern matching
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class GlobTool(BaseTool):
|
||||
"""Fast file pattern matching tool"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Glob"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Find files matching glob pattern
|
||||
|
||||
- Fast file pattern matching tool that works with any codebase size
|
||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||
- Returns matching file paths sorted by modification time
|
||||
"""
|
||||
pattern = params["pattern"]
|
||||
path = params.get("path", ".")
|
||||
if path is None:
|
||||
path = "."
|
||||
|
||||
# Resolve search path
|
||||
search_path = Path(path).expanduser().resolve()
|
||||
if not search_path.exists():
|
||||
return {"error": f"Path not found: {search_path}"}
|
||||
|
||||
if not search_path.is_dir():
|
||||
return {"error": f"Path is not a directory: {search_path}"}
|
||||
|
||||
# Ensure pattern starts with **/ for recursive search
|
||||
if not pattern.startswith("**/"):
|
||||
pattern = "**/" + pattern
|
||||
|
||||
# Find matching files
|
||||
matches = []
|
||||
try:
|
||||
for match in search_path.glob(pattern):
|
||||
if match.is_file():
|
||||
matches.append(str(match))
|
||||
except Exception as e:
|
||||
return {"error": f"Error in glob search: {str(e)}"}
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
try:
|
||||
matches.sort(key=lambda x: os.path.getmtime(x), reverse=True)
|
||||
except Exception:
|
||||
# If sorting fails, just use unsorted list
|
||||
pass
|
||||
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"search_path": str(search_path),
|
||||
"matches": matches,
|
||||
"total_matches": len(matches)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Grep tool - Pure Python implementation without rg/grep dependencies
|
||||
Implements full regex search across files with all features from tools.json
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
import fnmatch
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class GrepTool(BaseTool):
|
||||
"""Pure Python grep implementation"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Grep"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Search for patterns in files using pure Python regex
|
||||
|
||||
Supports:
|
||||
- Full regex syntax
|
||||
- Case insensitive search (-i)
|
||||
- Context lines (-A, -B, -C)
|
||||
- Line numbers (-n)
|
||||
- Multiline mode
|
||||
- Glob filtering
|
||||
- File type filtering
|
||||
- Output modes: content, files_with_matches, count
|
||||
- Head limit
|
||||
"""
|
||||
pattern = params["pattern"]
|
||||
path = params.get("path", ".")
|
||||
if path is None:
|
||||
path = "."
|
||||
glob_pattern = params.get("glob")
|
||||
output_mode = params.get("output_mode", "files_with_matches")
|
||||
case_insensitive = params.get("-i", False)
|
||||
context_before = params.get("-B")
|
||||
if context_before is None:
|
||||
context_before = 0
|
||||
context_after = params.get("-A")
|
||||
if context_after is None:
|
||||
context_after = 0
|
||||
context_around = params.get("-C")
|
||||
if context_around is None:
|
||||
context_around = 0
|
||||
show_line_numbers = params.get("-n", False)
|
||||
multiline = params.get("multiline", False)
|
||||
head_limit = params.get("head_limit")
|
||||
if head_limit is not None and head_limit < 0:
|
||||
head_limit = None
|
||||
# head_limit=0 means zero results (like `head -0`), not unlimited.
|
||||
if head_limit == 0:
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"output": "No matches found.",
|
||||
"matches": 0,
|
||||
}
|
||||
file_type = params.get("type")
|
||||
|
||||
# Determine context
|
||||
if context_around:
|
||||
context_before = context_around
|
||||
context_after = context_around
|
||||
context_before = max(0, int(context_before))
|
||||
context_after = max(0, int(context_after))
|
||||
|
||||
# Compile regex
|
||||
regex_flags = re.MULTILINE if multiline else 0
|
||||
if case_insensitive:
|
||||
regex_flags |= re.IGNORECASE
|
||||
if multiline:
|
||||
regex_flags |= re.DOTALL
|
||||
|
||||
try:
|
||||
regex = re.compile(pattern, regex_flags)
|
||||
except re.error as e:
|
||||
return {"error": f"Invalid regex pattern: {str(e)}"}
|
||||
|
||||
# Resolve search path
|
||||
search_path = Path(path).expanduser().resolve()
|
||||
if not search_path.exists():
|
||||
return {"error": f"Path not found: {search_path}"}
|
||||
|
||||
# Get files to search
|
||||
files_to_search = self._get_files_to_search(
|
||||
search_path, glob_pattern, file_type
|
||||
)
|
||||
|
||||
if not files_to_search:
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"output": "No files found matching criteria.",
|
||||
"matches": 0
|
||||
}
|
||||
|
||||
# Search files
|
||||
if output_mode == "files_with_matches":
|
||||
results = self._search_files_with_matches(files_to_search, regex, head_limit)
|
||||
elif output_mode == "count":
|
||||
results = self._search_count(files_to_search, regex, head_limit)
|
||||
else: # content
|
||||
results = self._search_content(
|
||||
files_to_search, regex,
|
||||
context_before, context_after,
|
||||
show_line_numbers, head_limit
|
||||
)
|
||||
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"output": results["output"],
|
||||
"matches": results["matches"]
|
||||
}
|
||||
|
||||
def _get_files_to_search(
|
||||
self, search_path: Path, glob_pattern: Optional[str], file_type: Optional[str]
|
||||
) -> List[Path]:
|
||||
"""Get list of files to search"""
|
||||
files = []
|
||||
|
||||
# Define file type extensions
|
||||
type_extensions = {
|
||||
"py": ["*.py"],
|
||||
"python": ["*.py"],
|
||||
"js": ["*.js", "*.jsx"],
|
||||
"javascript": ["*.js", "*.jsx"],
|
||||
"ts": ["*.ts", "*.tsx"],
|
||||
"typescript": ["*.ts", "*.tsx"],
|
||||
"java": ["*.java"],
|
||||
"go": ["*.go"],
|
||||
"rust": ["*.rs"],
|
||||
"cpp": ["*.cpp", "*.cc", "*.cxx", "*.h", "*.hpp"],
|
||||
"c": ["*.c", "*.h"],
|
||||
"ruby": ["*.rb"],
|
||||
"php": ["*.php"],
|
||||
"html": ["*.html", "*.htm"],
|
||||
"css": ["*.css"],
|
||||
"json": ["*.json"],
|
||||
"yaml": ["*.yaml", "*.yml"],
|
||||
"md": ["*.md"],
|
||||
"markdown": ["*.md"],
|
||||
"txt": ["*.txt"],
|
||||
}
|
||||
|
||||
if search_path.is_file():
|
||||
# Single file
|
||||
filename = search_path.name
|
||||
if file_type:
|
||||
extensions = type_extensions.get(file_type, [])
|
||||
if not any(fnmatch.fnmatch(filename, ext) for ext in extensions):
|
||||
return []
|
||||
if glob_pattern:
|
||||
if not (fnmatch.fnmatch(filename, glob_pattern) or fnmatch.fnmatch(str(search_path), glob_pattern)):
|
||||
return []
|
||||
files = [search_path]
|
||||
else:
|
||||
# Directory - walk recursively
|
||||
for root, dirs, filenames in os.walk(search_path):
|
||||
# Skip hidden directories
|
||||
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||
|
||||
for filename in filenames:
|
||||
# Skip hidden files
|
||||
if filename.startswith('.'):
|
||||
continue
|
||||
|
||||
file_path = Path(root) / filename
|
||||
|
||||
# Check file type filter
|
||||
if file_type:
|
||||
extensions = type_extensions.get(file_type, [])
|
||||
if not any(fnmatch.fnmatch(filename, ext) for ext in extensions):
|
||||
continue
|
||||
|
||||
# Check glob filter
|
||||
if glob_pattern:
|
||||
# Convert glob to relative path for matching
|
||||
try:
|
||||
rel_path = file_path.relative_to(search_path)
|
||||
if not fnmatch.fnmatch(str(rel_path), glob_pattern):
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
files.append(file_path)
|
||||
|
||||
return files
|
||||
|
||||
def _search_files_with_matches(
|
||||
self, files: List[Path], regex: re.Pattern, head_limit: Optional[int]
|
||||
) -> Dict[str, Any]:
|
||||
"""Search and return files that have matches"""
|
||||
matching_files = []
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
# Try to read as text
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for match
|
||||
if regex.search(content):
|
||||
matching_files.append(str(file_path))
|
||||
|
||||
# Check head limit (0 means zero results; do not treat as unlimited)
|
||||
if head_limit is not None and len(matching_files) >= head_limit:
|
||||
break
|
||||
|
||||
except (IOError, OSError, UnicodeDecodeError):
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
if not matching_files:
|
||||
output = "No matches found."
|
||||
else:
|
||||
output = "\n".join(matching_files)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
"matches": len(matching_files)
|
||||
}
|
||||
|
||||
def _search_count(
|
||||
self, files: List[Path], regex: re.Pattern, head_limit: Optional[int]
|
||||
) -> Dict[str, Any]:
|
||||
"""Count matches per file"""
|
||||
results = []
|
||||
total_matches = 0
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
# Count matches
|
||||
matches = len(regex.findall(content))
|
||||
if matches > 0:
|
||||
results.append(f"{file_path}:{matches}")
|
||||
total_matches += matches
|
||||
|
||||
if head_limit is not None and len(results) >= head_limit:
|
||||
break
|
||||
|
||||
except (IOError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
if not results:
|
||||
output = "No matches found."
|
||||
else:
|
||||
output = "\n".join(results)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
"matches": total_matches
|
||||
}
|
||||
|
||||
def _search_content(
|
||||
self, files: List[Path], regex: re.Pattern,
|
||||
context_before: int, context_after: int,
|
||||
show_line_numbers: bool, head_limit: Optional[int]
|
||||
) -> Dict[str, Any]:
|
||||
"""Search and return matching lines with context"""
|
||||
output_lines = []
|
||||
total_matches = 0
|
||||
lines_added = 0
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find matching lines
|
||||
matching_line_numbers = []
|
||||
if (regex.flags & re.DOTALL) or (regex.flags & re.MULTILINE):
|
||||
full_content = "".join(lines)
|
||||
for match in regex.finditer(full_content):
|
||||
start_pos, end_pos = match.span()
|
||||
start_line = full_content.count('\n', 0, start_pos)
|
||||
end_line = full_content.count('\n', 0, max(start_pos, end_pos - 1 if end_pos > start_pos else start_pos))
|
||||
matching_line_numbers.extend(range(start_line, end_line + 1))
|
||||
else:
|
||||
for i, line in enumerate(lines):
|
||||
if regex.search(line):
|
||||
matching_line_numbers.append(i)
|
||||
if not matching_line_numbers:
|
||||
continue
|
||||
|
||||
# Add file header
|
||||
output_lines.append(f"\n{file_path}")
|
||||
lines_added += 1
|
||||
|
||||
# Process each match with context
|
||||
lines_to_show = set()
|
||||
for line_num in matching_line_numbers:
|
||||
# Add context lines
|
||||
start = max(0, line_num - context_before)
|
||||
end = min(len(lines), line_num + context_after + 1)
|
||||
lines_to_show.update(range(start, end))
|
||||
|
||||
# Output lines in order
|
||||
prev_line = -2
|
||||
for line_num in sorted(lines_to_show):
|
||||
# Add separator for gaps
|
||||
if line_num > prev_line + 1:
|
||||
output_lines.append("--")
|
||||
lines_added += 1
|
||||
|
||||
line = lines[line_num].rstrip()
|
||||
is_match = line_num in matching_line_numbers
|
||||
|
||||
# Format line
|
||||
if show_line_numbers:
|
||||
prefix = f"{line_num + 1}:"
|
||||
else:
|
||||
prefix = ""
|
||||
|
||||
# Use : for match lines, - for context
|
||||
separator = ":" if is_match else "-"
|
||||
formatted = f"{prefix}{separator}{line}" if prefix else f"{separator}{line}"
|
||||
|
||||
output_lines.append(formatted)
|
||||
lines_added += 1
|
||||
|
||||
if is_match:
|
||||
total_matches += 1
|
||||
|
||||
prev_line = line_num
|
||||
|
||||
# Check head limit (0 means zero results; do not treat as unlimited)
|
||||
if head_limit is not None and lines_added >= head_limit:
|
||||
break
|
||||
|
||||
if head_limit is not None and lines_added >= head_limit:
|
||||
break
|
||||
|
||||
except (IOError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
if not output_lines:
|
||||
output = "No matches found."
|
||||
else:
|
||||
output = "\n".join(output_lines)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
"matches": total_matches
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
KillBash tool - Terminate shell sessions
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class KillBashTool(BaseTool):
|
||||
"""Kills a running background bash shell by its ID"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "KillBash"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Kill a shell session
|
||||
|
||||
- Kills a running background bash shell by its ID
|
||||
- Takes a shell_id parameter identifying the shell to kill
|
||||
- Returns a success or failure status
|
||||
"""
|
||||
shell_id = params["shell_id"]
|
||||
|
||||
if shell_id in self.state.shell_sessions:
|
||||
try:
|
||||
session = self.state.shell_sessions[shell_id]
|
||||
session.kill()
|
||||
del self.state.shell_sessions[shell_id]
|
||||
return {
|
||||
"shell_id": shell_id,
|
||||
"status": "terminated"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Error killing shell: {str(e)}"}
|
||||
|
||||
for session in self.state.shell_sessions.values():
|
||||
if shell_id in session.background_processes:
|
||||
try:
|
||||
proc = session.background_processes.pop(shell_id)
|
||||
if isinstance(proc, int):
|
||||
import os
|
||||
import signal
|
||||
try:
|
||||
os.kill(proc, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
session._terminate_process(proc)
|
||||
return {
|
||||
"shell_id": shell_id,
|
||||
"status": "terminated"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Error killing shell: {str(e)}"}
|
||||
|
||||
return {"error": f"Shell session not found: {shell_id}"}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
LS tool - Directory listing
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import fnmatch
|
||||
from typing import Dict, Any, List
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class LSTool(BaseTool):
|
||||
"""Lists files and directories"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "LS"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
List directory contents
|
||||
|
||||
- The path parameter must be an absolute path
|
||||
- You can optionally provide an array of glob patterns to ignore
|
||||
"""
|
||||
path = Path(params["path"]).expanduser().resolve()
|
||||
ignore_patterns = params.get("ignore")
|
||||
if ignore_patterns is None:
|
||||
ignore_patterns = []
|
||||
|
||||
if not path.exists():
|
||||
return {"error": f"Path not found: {path}"}
|
||||
|
||||
if not path.is_dir():
|
||||
return {"error": f"Not a directory: {path}"}
|
||||
|
||||
try:
|
||||
entries = []
|
||||
|
||||
for entry in sorted(path.iterdir()):
|
||||
# Skip hidden files (starting with .)
|
||||
if entry.name.startswith('.'):
|
||||
continue
|
||||
|
||||
# Check ignore patterns
|
||||
should_ignore = False
|
||||
for pattern in ignore_patterns:
|
||||
if fnmatch.fnmatch(entry.name, pattern):
|
||||
should_ignore = True
|
||||
break
|
||||
|
||||
if should_ignore:
|
||||
continue
|
||||
|
||||
# Get entry info
|
||||
entry_type = "dir" if entry.is_dir() else "file"
|
||||
size = entry.stat().st_size if entry.is_file() else 0
|
||||
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"type": entry_type,
|
||||
"size": size,
|
||||
"path": str(entry)
|
||||
})
|
||||
|
||||
return {
|
||||
"path": str(path),
|
||||
"entries": entries,
|
||||
"total_entries": len(entries)
|
||||
}
|
||||
|
||||
except PermissionError:
|
||||
return {"error": f"Permission denied: {path}"}
|
||||
except Exception as e:
|
||||
return {"error": f"Error listing directory: {str(e)}"}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
MultiEdit tool - Multiple edits to a single file in one operation
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class MultiEditTool(BaseTool):
|
||||
"""Makes multiple edits to a single file in one operation"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "MultiEdit"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Perform multiple edits on a file
|
||||
|
||||
- Built on top of Edit tool
|
||||
- All edits are applied in sequence, in the order they are provided
|
||||
- Each edit operates on the result of the previous edit
|
||||
- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
|
||||
- The edits are atomic - either all succeed or none are applied
|
||||
"""
|
||||
file_path = Path(params["file_path"]).expanduser().resolve()
|
||||
edits = params.get("edits")
|
||||
if edits is None:
|
||||
edits = []
|
||||
|
||||
creating_new = False
|
||||
if not file_path.exists():
|
||||
# Defer create/write until every edit succeeds (atomic).
|
||||
if edits and edits[0]["old_string"] == "":
|
||||
creating_new = True
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
return {"error": f"Error creating file: {str(e)}"}
|
||||
else:
|
||||
return {"error": f"File not found: {file_path}"}
|
||||
|
||||
try:
|
||||
if creating_new:
|
||||
content = ""
|
||||
else:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
original_content = content
|
||||
results = []
|
||||
|
||||
# Apply edits sequentially
|
||||
for i, edit in enumerate(edits):
|
||||
old_string = edit["old_string"]
|
||||
new_string = edit["new_string"]
|
||||
replace_all = edit.get("replace_all", False)
|
||||
|
||||
# Empty old_string only valid when creating a new file (tools.json / Edit parity).
|
||||
if old_string == "":
|
||||
if creating_new and i == 0:
|
||||
content = new_string
|
||||
results.append({"edit": i + 1, "action": "created", "success": True})
|
||||
continue
|
||||
return {"error": "old_string cannot be empty"}
|
||||
|
||||
if old_string not in content:
|
||||
return {
|
||||
"error": f"Edit #{i + 1} failed: String not found",
|
||||
"old_string": old_string[:100],
|
||||
"completed_edits": i
|
||||
}
|
||||
|
||||
occurrences = content.count(old_string)
|
||||
if not replace_all and occurrences > 1:
|
||||
return {
|
||||
"error": f"Edit #{i + 1} failed: String appears {occurrences} times",
|
||||
"completed_edits": i
|
||||
}
|
||||
|
||||
if replace_all:
|
||||
content = content.replace(old_string, new_string)
|
||||
replacements = occurrences
|
||||
else:
|
||||
content = content.replace(old_string, new_string, 1)
|
||||
replacements = 1
|
||||
|
||||
results.append({
|
||||
"edit": i + 1,
|
||||
"replacements": replacements,
|
||||
"success": True
|
||||
})
|
||||
|
||||
# Write back
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
result = {
|
||||
"file_path": str(file_path),
|
||||
"total_edits": len(edits),
|
||||
"successful_edits": len(results),
|
||||
"edit_results": results,
|
||||
"old_size": len(original_content),
|
||||
"new_size": len(content)
|
||||
}
|
||||
|
||||
# Check for lint errors
|
||||
lint_result = self._check_lint_errors(file_path)
|
||||
if lint_result:
|
||||
result["lint_check"] = lint_result
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Error in multi-edit: {str(e)}"}
|
||||
|
||||
def _check_lint_errors(self, file_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check for lint errors"""
|
||||
suffix = file_path.suffix
|
||||
try:
|
||||
if suffix == ".py":
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "py_compile", str(file_path)],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return {
|
||||
"language": "python",
|
||||
"has_errors": result.returncode != 0,
|
||||
"errors": result.stderr if result.returncode != 0 else None,
|
||||
"message": "No syntax errors detected" if result.returncode == 0 else None
|
||||
}
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
NotebookEdit tool - Edit Jupyter notebook cells
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class NotebookEditTool(BaseTool):
|
||||
"""Completely replaces the contents of a specific cell in a Jupyter notebook"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "NotebookEdit"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Edit Jupyter notebook cell
|
||||
|
||||
- Completely replaces the contents of a specific cell
|
||||
- The notebook_path parameter must be an absolute path
|
||||
- Use edit_mode=insert to add a new cell
|
||||
- Use edit_mode=delete to delete a cell
|
||||
- Use edit_mode=replace to replace cell contents (default)
|
||||
"""
|
||||
notebook_path = Path(params["notebook_path"]).expanduser().resolve()
|
||||
cell_id = params.get("cell_id")
|
||||
new_source = params.get("new_source")
|
||||
cell_type = params.get("cell_type", "code")
|
||||
edit_mode = params.get("edit_mode", "replace")
|
||||
|
||||
if not notebook_path.exists():
|
||||
return {"error": f"Notebook not found: {notebook_path}"}
|
||||
|
||||
try:
|
||||
# Load notebook
|
||||
with open(notebook_path, 'r', encoding='utf-8') as f:
|
||||
notebook = json.load(f)
|
||||
|
||||
cells = notebook.get('cells', [])
|
||||
|
||||
if edit_mode == "insert":
|
||||
if new_source is None:
|
||||
return {"error": "new_source required for insert mode"}
|
||||
# Insert new cell
|
||||
new_cell = {
|
||||
"cell_type": cell_type,
|
||||
"metadata": {},
|
||||
# nbformat stores source as a list of lines that KEEP their
|
||||
# trailing '\n'; readers rebuild the cell with ''.join(source).
|
||||
"source": new_source.splitlines(keepends=True)
|
||||
}
|
||||
|
||||
if cell_type == "code":
|
||||
new_cell["outputs"] = []
|
||||
new_cell["execution_count"] = None
|
||||
|
||||
# Find insertion point
|
||||
if cell_id is not None:
|
||||
# Insert after cell with given ID
|
||||
for i, cell in enumerate(cells):
|
||||
if str(cell.get('id')) == str(cell_id):
|
||||
cells.insert(i + 1, new_cell)
|
||||
break
|
||||
else:
|
||||
return {"error": f"Cell with ID {cell_id} not found"}
|
||||
else:
|
||||
# Insert at beginning
|
||||
cells.insert(0, new_cell)
|
||||
|
||||
action = "inserted"
|
||||
|
||||
elif edit_mode == "delete":
|
||||
# Delete cell
|
||||
if cell_id is not None:
|
||||
for i, cell in enumerate(cells):
|
||||
if str(cell.get('id')) == str(cell_id):
|
||||
cells.pop(i)
|
||||
break
|
||||
else:
|
||||
return {"error": f"Cell with ID {cell_id} not found"}
|
||||
else:
|
||||
return {"error": "cell_id required for delete mode"}
|
||||
|
||||
action = "deleted"
|
||||
|
||||
else: # replace
|
||||
if new_source is None:
|
||||
return {"error": "new_source required for replace mode"}
|
||||
# Replace cell contents
|
||||
if cell_id is not None:
|
||||
for cell in cells:
|
||||
if str(cell.get('id')) == str(cell_id):
|
||||
cell["source"] = new_source.splitlines(keepends=True)
|
||||
if cell_type:
|
||||
cell["cell_type"] = cell_type
|
||||
break
|
||||
else:
|
||||
return {"error": f"Cell with ID {cell_id} not found"}
|
||||
else:
|
||||
return {"error": "cell_id required for replace mode"}
|
||||
|
||||
action = "replaced"
|
||||
|
||||
# Update notebook
|
||||
notebook["cells"] = cells
|
||||
|
||||
# Write back
|
||||
with open(notebook_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(notebook, f, indent=1, ensure_ascii=False)
|
||||
|
||||
return {
|
||||
"notebook_path": str(notebook_path),
|
||||
"action": action,
|
||||
"total_cells": len(cells)
|
||||
}
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Invalid Jupyter notebook format"}
|
||||
except Exception as e:
|
||||
return {"error": f"Error editing notebook: {str(e)}"}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Read tool - File reading with support for text, images, PDFs, and notebooks
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class ReadTool(BaseTool):
|
||||
"""Reads files from the local filesystem"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Read"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Read file contents
|
||||
|
||||
- The file_path parameter must be an absolute path
|
||||
- By default, reads up to 2000 lines from the beginning
|
||||
- Can specify offset and limit for large files
|
||||
- Lines longer than 2000 characters are truncated
|
||||
- Results returned in cat -n format with line numbers starting at 1
|
||||
- Supports images, PDFs, Jupyter notebooks
|
||||
"""
|
||||
file_path = Path(params["file_path"]).expanduser().resolve()
|
||||
offset = params.get("offset")
|
||||
if offset is None:
|
||||
offset = 0
|
||||
limit = params.get("limit")
|
||||
if limit is None:
|
||||
limit = 2000
|
||||
|
||||
if not file_path.exists():
|
||||
return {"error": f"File not found: {file_path}"}
|
||||
|
||||
if not file_path.is_file():
|
||||
return {"error": f"Not a file: {file_path}"}
|
||||
|
||||
# Check file type
|
||||
suffix = file_path.suffix.lower()
|
||||
|
||||
# Handle special file types
|
||||
if suffix in ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']:
|
||||
return self._read_image(file_path)
|
||||
elif suffix == '.pdf':
|
||||
return self._read_pdf(file_path)
|
||||
elif suffix == '.ipynb':
|
||||
return self._read_notebook(file_path)
|
||||
else:
|
||||
return self._read_text(file_path, offset, limit)
|
||||
|
||||
def _read_text(self, file_path: Path, offset: int, limit: int) -> Dict[str, Any]:
|
||||
"""Read text file"""
|
||||
try:
|
||||
# Sniff for binary content first: NUL bytes never appear in text,
|
||||
# and control bytes like \x00-\x05 are valid UTF-8, so a decode
|
||||
# error alone is not a reliable binary signal.
|
||||
with open(file_path, 'rb') as f:
|
||||
sample = f.read(8192)
|
||||
if b'\x00' in sample:
|
||||
return {"error": "File appears to be binary. Cannot read as text."}
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Apply offset and limit
|
||||
total_lines = len(lines)
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
if limit < 0:
|
||||
selected_lines = lines[offset:]
|
||||
else:
|
||||
selected_lines = lines[offset:offset + limit] if offset or limit < total_lines else lines
|
||||
|
||||
# Format with line numbers (1-indexed)
|
||||
formatted_lines = []
|
||||
for i, line in enumerate(selected_lines, start=offset + 1):
|
||||
# Truncate long lines
|
||||
line_content = line.rstrip()
|
||||
if len(line_content) > 2000:
|
||||
line_content = line_content[:2000] + "... (line truncated)"
|
||||
formatted_lines.append(f"{i:6d}|{line_content}")
|
||||
|
||||
content = "\n".join(formatted_lines)
|
||||
|
||||
# tools.json: empty-file warning only when the file has no contents.
|
||||
if total_lines == 0:
|
||||
content = "File is empty."
|
||||
elif not selected_lines:
|
||||
content = "No lines in selected range."
|
||||
|
||||
return {
|
||||
"file_path": str(file_path),
|
||||
"total_lines": total_lines,
|
||||
"showing_lines": f"{offset + 1}-{offset + len(selected_lines)}",
|
||||
"content": content
|
||||
}
|
||||
|
||||
except UnicodeDecodeError:
|
||||
return {"error": "File appears to be binary. Cannot read as text."}
|
||||
except Exception as e:
|
||||
return {"error": f"Error reading file: {str(e)}"}
|
||||
|
||||
def _read_image(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""Read image file"""
|
||||
# For now, just return metadata since we can't display images in text
|
||||
try:
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"file_path": str(file_path),
|
||||
"file_type": "image",
|
||||
"format": file_path.suffix[1:].upper(),
|
||||
"size_bytes": size,
|
||||
"note": "Image file detected. Full visual analysis requires multimodal LLM support."
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Error reading image: {str(e)}"}
|
||||
|
||||
def _read_pdf(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""Read PDF file"""
|
||||
# For now, return basic info
|
||||
# Full PDF support would require PyPDF2 or similar
|
||||
try:
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"file_path": str(file_path),
|
||||
"file_type": "pdf",
|
||||
"size_bytes": size,
|
||||
"note": "PDF file detected. Full text extraction requires PyPDF2 library. Install with: pip install PyPDF2"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"Error reading PDF: {str(e)}"}
|
||||
|
||||
def _read_notebook(self, file_path: Path) -> Dict[str, Any]:
|
||||
"""Read Jupyter notebook"""
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
notebook = json.load(f)
|
||||
|
||||
# Extract cells
|
||||
cells = notebook.get('cells', [])
|
||||
|
||||
# Format output
|
||||
output_lines = []
|
||||
output_lines.append(f"Jupyter Notebook: {file_path.name}")
|
||||
output_lines.append("=" * 60)
|
||||
|
||||
for i, cell in enumerate(cells):
|
||||
cell_type = cell.get('cell_type', 'unknown')
|
||||
source = cell.get('source', [])
|
||||
|
||||
# Convert source to string
|
||||
if isinstance(source, list):
|
||||
source_text = ''.join(source)
|
||||
else:
|
||||
source_text = source
|
||||
|
||||
output_lines.append(f"\n[Cell {i + 1}] Type: {cell_type}")
|
||||
output_lines.append("-" * 60)
|
||||
output_lines.append(source_text)
|
||||
|
||||
# Show outputs for code cells
|
||||
if cell_type == 'code':
|
||||
outputs = cell.get('outputs', [])
|
||||
if outputs:
|
||||
output_lines.append("\nOutput:")
|
||||
for output in outputs:
|
||||
output_type = output.get('output_type', '')
|
||||
if output_type == 'stream':
|
||||
text = ''.join(output.get('text', []))
|
||||
output_lines.append(text)
|
||||
elif output_type == 'execute_result' or output_type == 'display_data':
|
||||
data = output.get('data', {})
|
||||
if 'text/plain' in data:
|
||||
text = ''.join(data['text/plain'])
|
||||
output_lines.append(text)
|
||||
|
||||
content = '\n'.join(output_lines)
|
||||
|
||||
return {
|
||||
"file_path": str(file_path),
|
||||
"file_type": "jupyter_notebook",
|
||||
"total_cells": len(cells),
|
||||
"content": content
|
||||
}
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "Invalid Jupyter notebook format"}
|
||||
except Exception as e:
|
||||
return {"error": f"Error reading notebook: {str(e)}"}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Cross-platform shell session management for persistent command execution.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, TextIO, Tuple, Union
|
||||
|
||||
|
||||
def get_background_log_path(job_id: str) -> str:
|
||||
"""Return a platform-appropriate path for a background job log."""
|
||||
return os.path.join(tempfile.gettempdir(), f"{job_id}.log")
|
||||
|
||||
|
||||
def _get_shell_configuration(platform_name: Optional[str] = None) -> Tuple[str, List[str]]:
|
||||
"""Return the shell dialect and command for the current platform."""
|
||||
platform_name = platform_name or os.name
|
||||
|
||||
if platform_name == "nt":
|
||||
# PowerShell is available by default on supported Windows versions and
|
||||
# accepts common commands such as `python`, `git`, and `ls`. Prefer the
|
||||
# newer cross-platform edition when the user has installed it.
|
||||
powershell = shutil.which("pwsh") or shutil.which("powershell")
|
||||
if powershell:
|
||||
return "powershell", [
|
||||
powershell,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"-",
|
||||
]
|
||||
|
||||
# COMSPEC is a last-resort fallback for stripped-down Windows images
|
||||
# where PowerShell is unavailable.
|
||||
return "cmd", [os.environ.get("COMSPEC", "cmd.exe"), "/D", "/Q"]
|
||||
|
||||
bash = shutil.which("bash") or "/bin/bash"
|
||||
return "bash", [bash]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShellSession:
|
||||
"""Manage a persistent native shell session."""
|
||||
|
||||
session_id: str
|
||||
process: Optional[subprocess.Popen] = None
|
||||
current_directory: str = field(default_factory=os.getcwd)
|
||||
env: Dict[str, str] = field(default_factory=lambda: os.environ.copy())
|
||||
output_buffer: str = ""
|
||||
shell_kind: str = field(default="", init=False)
|
||||
shell_command: List[str] = field(default_factory=list, init=False, repr=False)
|
||||
background_processes: Dict[str, Union[subprocess.Popen, int]] = field(
|
||||
default_factory=dict, init=False, repr=False
|
||||
)
|
||||
|
||||
def _configure_shell(self) -> None:
|
||||
if not self.shell_command:
|
||||
self.shell_kind, self.shell_command = _get_shell_configuration()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the persistent shell process."""
|
||||
if self.process is None or self.process.poll() is not None:
|
||||
self._configure_shell()
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
cwd=self.current_directory,
|
||||
env=self.env,
|
||||
)
|
||||
# Reader thread feeds stdout lines into a queue so that execute()
|
||||
# can wait on them with a real timeout (a bare readline() would
|
||||
# block forever on silent commands like `sleep`).
|
||||
self._output_queue = queue.Queue()
|
||||
reader = threading.Thread(
|
||||
target=self._read_stdout,
|
||||
args=(self.process, self._output_queue),
|
||||
daemon=True,
|
||||
)
|
||||
reader.start()
|
||||
|
||||
@staticmethod
|
||||
def _read_stdout(process: subprocess.Popen, output_queue: queue.Queue) -> None:
|
||||
"""Pump stdout lines into the queue; None marks end of stream."""
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
output_queue.put(line)
|
||||
output_queue.put(None)
|
||||
|
||||
@staticmethod
|
||||
def _quote_powershell(value: str) -> str:
|
||||
"""Quote a string as a PowerShell single-quoted literal."""
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
def _build_command_script(
|
||||
self,
|
||||
command: str,
|
||||
done_marker: str,
|
||||
cwd_marker: str,
|
||||
env_start_marker: str = "",
|
||||
env_end_marker: str = "",
|
||||
) -> str:
|
||||
"""Wrap a command with platform-specific completion markers."""
|
||||
if self.shell_kind == "powershell":
|
||||
cwd = self._quote_powershell(self.current_directory)
|
||||
# Capture status inside the generated script block immediately
|
||||
# after the user's final statement. Checking `$?` after invoking a
|
||||
# script block can incorrectly turn command-not-found into success.
|
||||
command_with_status = (
|
||||
f"{command}\n"
|
||||
"$global:__agent_command_succeeded = $?\n"
|
||||
"$global:__agent_native_exit_code = $LASTEXITCODE"
|
||||
)
|
||||
encoded_command = base64.b64encode(
|
||||
command_with_status.encode("utf-8")
|
||||
).decode("ascii")
|
||||
return (
|
||||
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; "
|
||||
"$OutputEncoding = [Console]::OutputEncoding; "
|
||||
f"Set-Location -LiteralPath {cwd}; "
|
||||
"$global:LASTEXITCODE = $null; "
|
||||
"$global:__agent_command_succeeded = $false; "
|
||||
"$global:__agent_native_exit_code = $null; "
|
||||
"$__agent_command = [Text.Encoding]::UTF8.GetString("
|
||||
f"[Convert]::FromBase64String('{encoded_command}')); "
|
||||
"& ([ScriptBlock]::Create($__agent_command)); "
|
||||
"$__agent_exit_code = $global:__agent_native_exit_code; "
|
||||
"if ($null -eq $__agent_exit_code) { "
|
||||
" if ($global:__agent_command_succeeded) { $__agent_exit_code = 0 } "
|
||||
"else { $__agent_exit_code = 1 } "
|
||||
"}; "
|
||||
f"Write-Output ('{done_marker}' + $__agent_exit_code); "
|
||||
f"Write-Output '{env_start_marker}'; "
|
||||
"Get-ChildItem Env: | ForEach-Object { "
|
||||
"Write-Output ($_.Name + '=' + $_.Value) }; "
|
||||
f"Write-Output '{env_end_marker}'; "
|
||||
f"Write-Output ('{cwd_marker}' + (Get-Location).Path)\n"
|
||||
)
|
||||
|
||||
if self.shell_kind == "cmd":
|
||||
# Double quotes are sufficient for normal Windows paths. A quote
|
||||
# cannot occur in a Windows file or directory name.
|
||||
cwd = f'"{self.current_directory}"'
|
||||
return (
|
||||
"chcp 65001 > nul\n"
|
||||
f"cd /d {cwd}\n"
|
||||
f"{command}\n"
|
||||
'set "__agent_exit_code=%errorlevel%"\n'
|
||||
f"echo {done_marker}%__agent_exit_code%\n"
|
||||
f"echo {env_start_marker}\n"
|
||||
"set\n"
|
||||
f"echo {env_end_marker}\n"
|
||||
f"echo {cwd_marker}%CD%\n"
|
||||
)
|
||||
|
||||
cwd = shlex.quote(self.current_directory)
|
||||
return (
|
||||
f"cd {cwd}\n"
|
||||
"{\n"
|
||||
f"{command}\n"
|
||||
"}\n"
|
||||
"__agent_exit_code=$?\n"
|
||||
f"printf '%s%s\\n' '{done_marker}' \"$__agent_exit_code\"\n"
|
||||
f"printf '%s%s\\n' '{cwd_marker}' \"$PWD\"\n"
|
||||
)
|
||||
|
||||
def _parse_protocol_output(
|
||||
self,
|
||||
output_lines: List[str],
|
||||
done_marker: str,
|
||||
cwd_marker: str,
|
||||
env_start_marker: str = "",
|
||||
env_end_marker: str = "",
|
||||
fallback_exit_code: int = -1,
|
||||
) -> Tuple[str, int]:
|
||||
"""Remove internal markers and apply shell state from command output."""
|
||||
command_output = []
|
||||
environment_lines = []
|
||||
reading_environment = False
|
||||
exit_code = fallback_exit_code
|
||||
|
||||
for line in output_lines:
|
||||
stripped = line.rstrip("\r\n")
|
||||
match = re.fullmatch(re.escape(done_marker) + r"(-?\d+)", stripped)
|
||||
if match:
|
||||
exit_code = int(match.group(1))
|
||||
continue
|
||||
|
||||
if env_start_marker and stripped == env_start_marker:
|
||||
reading_environment = True
|
||||
continue
|
||||
if env_end_marker and stripped == env_end_marker:
|
||||
reading_environment = False
|
||||
updated_environment = {}
|
||||
for entry in environment_lines:
|
||||
name, separator, value = entry.partition("=")
|
||||
if separator and name:
|
||||
updated_environment[name] = value
|
||||
if updated_environment:
|
||||
self.env = updated_environment
|
||||
continue
|
||||
if reading_environment:
|
||||
environment_lines.append(stripped)
|
||||
continue
|
||||
|
||||
if stripped.startswith(cwd_marker) and exit_code != -1:
|
||||
new_directory = stripped[len(cwd_marker):]
|
||||
if new_directory and os.path.isdir(new_directory):
|
||||
self.current_directory = new_directory
|
||||
continue
|
||||
|
||||
command_output.append(stripped)
|
||||
|
||||
return "\n".join(command_output), exit_code
|
||||
|
||||
def _restart(self) -> None:
|
||||
"""Replace a stuck shell while retaining session state."""
|
||||
self._terminate_process(self.process)
|
||||
self.process = None
|
||||
self.start()
|
||||
|
||||
@staticmethod
|
||||
def _terminate_process(process: Optional[Union[subprocess.Popen, int]]) -> None:
|
||||
if process is None:
|
||||
return
|
||||
if isinstance(process, int):
|
||||
try:
|
||||
os.kill(process, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return
|
||||
if process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
def execute(self, command: str, timeout: float = 120) -> Tuple[str, int]:
|
||||
"""Execute a command in the persistent native shell."""
|
||||
self._configure_shell()
|
||||
|
||||
# PowerShell and cmd read redirected stdin through end-of-file rather
|
||||
# than executing it incrementally. Run one process per Windows command
|
||||
# and carry its directory/environment forward to preserve session state.
|
||||
if self.shell_kind in {"powershell", "cmd"}:
|
||||
return self._execute_windows(command, timeout)
|
||||
|
||||
self.start()
|
||||
|
||||
try:
|
||||
# A per-command nonce prevents command output that happens to look
|
||||
# like a protocol marker from truncating or desynchronizing output.
|
||||
nonce = uuid.uuid4().hex
|
||||
done_marker = f"__CMD_DONE_{nonce}__"
|
||||
cwd_marker = f"__CMD_CWD_{nonce}__"
|
||||
script = self._build_command_script(command, done_marker, cwd_marker)
|
||||
|
||||
self.process.stdin.write(script)
|
||||
self.process.stdin.flush()
|
||||
|
||||
output_lines = []
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
# Replace the stuck shell so its pending marker cannot
|
||||
# corrupt output from the next command.
|
||||
self._restart()
|
||||
return f"Command timed out (timeout: {timeout}s)", -1
|
||||
|
||||
try:
|
||||
line = self._output_queue.get(timeout=remaining)
|
||||
except queue.Empty:
|
||||
self._restart()
|
||||
return f"Command timed out (timeout: {timeout}s)", -1
|
||||
|
||||
if line is None: # shell exited unexpectedly
|
||||
break
|
||||
|
||||
stripped = line.rstrip("\r\n")
|
||||
output_lines.append(stripped)
|
||||
if stripped.startswith(cwd_marker):
|
||||
break
|
||||
|
||||
return self._parse_protocol_output(
|
||||
output_lines,
|
||||
done_marker,
|
||||
cwd_marker,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
return f"Error executing command: {exc}", -1
|
||||
|
||||
def _execute_windows(self, command: str, timeout: float) -> Tuple[str, int]:
|
||||
"""Execute one Windows command while preserving logical session state."""
|
||||
nonce = uuid.uuid4().hex
|
||||
done_marker = f"__CMD_DONE_{nonce}__"
|
||||
cwd_marker = f"__CMD_CWD_{nonce}__"
|
||||
env_start_marker = f"__CMD_ENV_START_{nonce}__"
|
||||
env_end_marker = f"__CMD_ENV_END_{nonce}__"
|
||||
script = self._build_command_script(
|
||||
command,
|
||||
done_marker,
|
||||
cwd_marker,
|
||||
env_start_marker,
|
||||
env_end_marker,
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=self.current_directory,
|
||||
env=self.env,
|
||||
)
|
||||
try:
|
||||
output, _ = process.communicate(script, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._terminate_process(process)
|
||||
return f"Command timed out (timeout: {timeout}s)", -1
|
||||
except Exception:
|
||||
self._terminate_process(process)
|
||||
raise
|
||||
|
||||
return self._parse_protocol_output(
|
||||
output.splitlines(),
|
||||
done_marker,
|
||||
cwd_marker,
|
||||
env_start_marker,
|
||||
env_end_marker,
|
||||
fallback_exit_code=process.returncode,
|
||||
)
|
||||
|
||||
def _background_shell_command(self, command: str) -> List[str]:
|
||||
"""Build a one-shot shell command for a background process."""
|
||||
self._configure_shell()
|
||||
executable = self.shell_command[0]
|
||||
|
||||
if self.shell_kind == "powershell":
|
||||
encoded_command = base64.b64encode(
|
||||
(
|
||||
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; "
|
||||
"$OutputEncoding = [Console]::OutputEncoding; "
|
||||
+ command
|
||||
).encode("utf-16-le")
|
||||
).decode("ascii")
|
||||
return [
|
||||
executable,
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-EncodedCommand",
|
||||
encoded_command,
|
||||
]
|
||||
if self.shell_kind == "cmd":
|
||||
return [executable, "/D", "/S", "/C", f"chcp 65001 > nul & {command}"]
|
||||
return [executable, "-c", command]
|
||||
|
||||
def start_background(self, command: str, job_id: str) -> int:
|
||||
"""Start a command in a separate process and log combined output."""
|
||||
log_path = get_background_log_path(job_id)
|
||||
|
||||
# Keep POSIX background jobs in the persistent Bash process so exports
|
||||
# made by earlier commands remain visible, matching the original tool
|
||||
# behavior. Windows commands use one-shot native shell processes.
|
||||
self._configure_shell()
|
||||
if self.shell_kind == "bash":
|
||||
background_command = (
|
||||
f"( {command} ) > {shlex.quote(log_path)} 2>&1 & echo $!"
|
||||
)
|
||||
output, exit_code = self.execute(background_command, timeout=5)
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(f"Unable to start background command: {output}")
|
||||
try:
|
||||
pid = int(output.strip().splitlines()[-1])
|
||||
self.background_processes[job_id] = pid
|
||||
return pid
|
||||
except (IndexError, ValueError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Unable to determine background command PID: {output}"
|
||||
) from exc
|
||||
|
||||
log_handle = open(log_path, "w", encoding="utf-8")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
self._background_shell_command(command),
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=self.current_directory,
|
||||
env=self.env,
|
||||
text=True,
|
||||
)
|
||||
except Exception:
|
||||
log_handle.close()
|
||||
try:
|
||||
os.remove(log_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
self.background_processes[job_id] = process
|
||||
closer = threading.Thread(
|
||||
target=self._close_log_when_done,
|
||||
args=(process, log_handle),
|
||||
daemon=True,
|
||||
)
|
||||
closer.start()
|
||||
return process.pid
|
||||
|
||||
@staticmethod
|
||||
def _close_log_when_done(process: subprocess.Popen, log_handle: TextIO) -> None:
|
||||
process.wait()
|
||||
log_handle.close()
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Terminate the persistent shell and its background processes."""
|
||||
self._terminate_process(self.process)
|
||||
for process in list(self.background_processes.values()):
|
||||
self._terminate_process(process)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Task tool - Launch sub-agents for complex tasks
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class TaskTool(BaseTool):
|
||||
"""Launch a new agent to handle complex, multi-step tasks autonomously"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Task"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Launch sub-agent
|
||||
|
||||
- Launch a new agent to handle complex, multi-step tasks autonomously
|
||||
- Available agent types: general-purpose, statusline-setup, output-style-setup
|
||||
- NOTE: This is a stub implementation. Full implementation would require:
|
||||
- Recursive agent instantiation
|
||||
- Isolated execution context
|
||||
- Result aggregation
|
||||
"""
|
||||
description = params["description"]
|
||||
prompt = params["prompt"]
|
||||
subagent_type = params["subagent_type"]
|
||||
|
||||
return {
|
||||
"description": description,
|
||||
"subagent_type": subagent_type,
|
||||
"error": "Task tool (sub-agents) not yet implemented",
|
||||
"note": "This tool would launch a specialized sub-agent to handle the task autonomously"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
TodoWrite tool - Task list management
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class TodoWriteTool(BaseTool):
|
||||
"""Creates and manages structured task lists"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "TodoWrite"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Update TODO list
|
||||
|
||||
- Use this tool to create and manage a structured task list
|
||||
- Track progress, organize complex tasks
|
||||
- Helps user understand progress
|
||||
"""
|
||||
# JSON null for required todos: same as empty list (LLM omit-as-null).
|
||||
todos = params["todos"]
|
||||
if todos is None:
|
||||
todos = []
|
||||
|
||||
# Validate todo format
|
||||
for todo in todos:
|
||||
if not isinstance(todo, dict) or not all(k in todo for k in ["id", "content", "status"]):
|
||||
return {"error": "Each todo must be a dict and must have id, content, and status"}
|
||||
if todo["status"] not in ["pending", "in_progress", "completed"]:
|
||||
return {"error": f"Invalid status: {todo['status']}"}
|
||||
|
||||
# Update state
|
||||
self.state.todos = todos
|
||||
|
||||
return {
|
||||
"total_todos": len(todos),
|
||||
"pending": sum(1 for t in todos if t["status"] == "pending"),
|
||||
"in_progress": sum(1 for t in todos if t["status"] == "in_progress"),
|
||||
"completed": sum(1 for t in todos if t["status"] == "completed")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
WebFetch tool - Fetch and analyze web content
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class WebFetchTool(BaseTool):
|
||||
"""Fetches content from a specified URL and processes it"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "WebFetch"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch content from URL
|
||||
|
||||
- Fetches content from a specified URL and processes it using an AI model
|
||||
- Takes a URL and a prompt as input
|
||||
- Fetches the URL content, converts HTML to markdown
|
||||
- Returns the model's response about the content
|
||||
- NOTE: This is a stub implementation. Full implementation would require:
|
||||
- requests library for HTTP
|
||||
- beautifulsoup4 for HTML parsing
|
||||
- html2text for markdown conversion
|
||||
"""
|
||||
url = params["url"]
|
||||
prompt = params["prompt"]
|
||||
|
||||
return {
|
||||
"url": url,
|
||||
"error": "WebFetch tool requires additional dependencies. Install with: pip install requests beautifulsoup4 html2text",
|
||||
"note": "This tool would fetch web content and process it with the given prompt"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
WebSearch tool - Search the web
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class WebSearchTool(BaseTool):
|
||||
"""Allows Claude to search the web and use the results"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "WebSearch"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Search the web
|
||||
|
||||
- Allows Claude to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- NOTE: This is a stub implementation. Full implementation would require
|
||||
API integration with search services like Google, Bing, or DuckDuckGo
|
||||
"""
|
||||
query = params["query"]
|
||||
allowed_domains = params.get("allowed_domains", [])
|
||||
blocked_domains = params.get("blocked_domains", [])
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"error": "WebSearch tool requires API integration with a search service",
|
||||
"note": "This tool would search the web with the given query and filters"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Write tool - File writing with automatic lint checking
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from .base import BaseTool
|
||||
|
||||
|
||||
class WriteTool(BaseTool):
|
||||
"""Writes files to the local filesystem"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Write"
|
||||
|
||||
def _execute_impl(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Write content to file
|
||||
|
||||
- This tool will overwrite the existing file if there is one at the provided path
|
||||
- ALWAYS prefer editing existing files in the codebase
|
||||
- NEVER write new files unless explicitly required
|
||||
- NEVER proactively create documentation files (*.md) or README files
|
||||
"""
|
||||
file_path = Path(params["file_path"]).expanduser().resolve()
|
||||
content = params["content"]
|
||||
|
||||
try:
|
||||
# Create parent directories if needed
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
result = {
|
||||
"file_path": str(file_path),
|
||||
"bytes_written": len(content.encode('utf-8')),
|
||||
"lines_written": len(content.split('\n'))
|
||||
}
|
||||
|
||||
# Check for lint errors
|
||||
lint_result = self._check_lint_errors(file_path)
|
||||
if lint_result:
|
||||
result["lint_check"] = lint_result
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Error writing file: {str(e)}"}
|
||||
|
||||
def _check_lint_errors(self, file_path: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Check for lint errors after file modification"""
|
||||
suffix = file_path.suffix
|
||||
|
||||
try:
|
||||
if suffix == ".py":
|
||||
# Check Python syntax
|
||||
result = subprocess.run(
|
||||
["python3", "-m", "py_compile", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"language": "python",
|
||||
"has_errors": True,
|
||||
"errors": result.stderr
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"language": "python",
|
||||
"has_errors": False,
|
||||
"message": "No syntax errors detected"
|
||||
}
|
||||
|
||||
elif suffix in [".js", ".jsx", ".ts", ".tsx"]:
|
||||
# Check JavaScript/TypeScript with node if available
|
||||
result = subprocess.run(
|
||||
["node", "--check", str(file_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"language": "javascript/typescript",
|
||||
"has_errors": True,
|
||||
"errors": result.stderr
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"language": "javascript/typescript",
|
||||
"has_errors": False,
|
||||
"message": "No syntax errors detected"
|
||||
}
|
||||
|
||||
# No linter available for this file type
|
||||
return None
|
||||
|
||||
except FileNotFoundError:
|
||||
# Linter not installed
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "Lint check timed out"}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user