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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+46
View File
@@ -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'
]
+98
View File
@@ -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)}"}
+75
View File
@@ -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
}
+138
View File
@@ -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."
}
+65
View File
@@ -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)
}
+353
View File
@@ -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}"}
+76
View File
@@ -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)}"}
+197
View File
@@ -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)
+37
View File
@@ -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"
}
+111
View File
@@ -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