Rebase onto upstream (a4d95fd)
#12
@@ -7,6 +7,7 @@ from nanobot.agent.tools.anthropic.base import (
|
|||||||
ToolError,
|
ToolError,
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
from nanobot.agent.tools.anthropic.bash import BashTool20250124
|
||||||
|
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAnthropicTool",
|
"BaseAnthropicTool",
|
||||||
@@ -14,4 +15,5 @@ __all__ = [
|
|||||||
"CLIResult",
|
"CLIResult",
|
||||||
"ToolError",
|
"ToolError",
|
||||||
"BashTool20250124",
|
"BashTool20250124",
|
||||||
|
"EditTool20250728",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class CLIResult:
|
|||||||
|
|
||||||
Similar to ToolResult but simpler for text-only tools.
|
Similar to ToolResult but simpler for text-only tools.
|
||||||
"""
|
"""
|
||||||
|
exit_code: int
|
||||||
output: str
|
output: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
class ToolError(Exception):
|
class ToolError(Exception):
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
EditTool20250728 - File editor with view/create/str_replace/insert commands.
|
||||||
|
|
||||||
|
Anthropic's native trained tool for file editing operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from .base import BaseAnthropicTool, CLIResult
|
||||||
|
|
||||||
|
|
||||||
|
class EditTool20250728(BaseAnthropicTool):
|
||||||
|
"""
|
||||||
|
File editor supporting view, create, str_replace, and insert operations.
|
||||||
|
|
||||||
|
Trained by Anthropic, this tool provides comprehensive file editing
|
||||||
|
capabilities with strict safety checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
api_type = "text_editor_20250728"
|
||||||
|
name = "str_replace_editor"
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
command: Literal["view", "create", "str_replace", "insert"],
|
||||||
|
path: str,
|
||||||
|
file_text: str | None = None,
|
||||||
|
old_str: str | None = None,
|
||||||
|
new_str: str | None = None,
|
||||||
|
insert_line: int | None = None,
|
||||||
|
view_range: list[int] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> CLIResult:
|
||||||
|
"""
|
||||||
|
Execute a file editing command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The operation to perform
|
||||||
|
path: Absolute path to the file
|
||||||
|
file_text: Full file content (for create)
|
||||||
|
old_str: String to replace (for str_replace)
|
||||||
|
new_str: Replacement string (for str_replace/insert)
|
||||||
|
insert_line: Line number to insert at (for insert)
|
||||||
|
view_range: [start, end] line range (for view)
|
||||||
|
**kwargs: Additional arguments (ignored)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CLIResult with exit code, output, and error
|
||||||
|
"""
|
||||||
|
# Validate absolute path
|
||||||
|
file_path = Path(path)
|
||||||
|
if not file_path.is_absolute():
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: path must be absolute, got: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if command == "view":
|
||||||
|
return await self._view(file_path, view_range)
|
||||||
|
elif command == "create":
|
||||||
|
return await self._create(file_path, file_text)
|
||||||
|
elif command == "str_replace":
|
||||||
|
return await self._str_replace(file_path, old_str, new_str)
|
||||||
|
elif command == "insert":
|
||||||
|
return await self._insert(file_path, insert_line, new_str)
|
||||||
|
else:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: unknown command: {command}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _view(self, path: Path, view_range: list[int] | None) -> CLIResult:
|
||||||
|
"""View file contents with line numbers."""
|
||||||
|
if not path.exists():
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: file not found: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = path.read_text()
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
|
||||||
|
# Apply view range if specified
|
||||||
|
if view_range:
|
||||||
|
start, end = view_range
|
||||||
|
lines = lines[start - 1:end]
|
||||||
|
start_num = start
|
||||||
|
else:
|
||||||
|
start_num = 1
|
||||||
|
|
||||||
|
# Format with line numbers
|
||||||
|
formatted_lines = [
|
||||||
|
f"{start_num + i}|{line.rstrip()}"
|
||||||
|
for i, line in enumerate(lines)
|
||||||
|
]
|
||||||
|
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=0,
|
||||||
|
output="\n".join(formatted_lines),
|
||||||
|
error=""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _create(self, path: Path, file_text: str | None) -> CLIResult:
|
||||||
|
"""Create a new file with the given content."""
|
||||||
|
if file_text is None:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error="Error: file_text is required for create command"
|
||||||
|
)
|
||||||
|
|
||||||
|
if path.exists():
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: file already exists: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create parent directories if needed
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write the file
|
||||||
|
path.write_text(file_text)
|
||||||
|
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=0,
|
||||||
|
output=f"File created: {path}",
|
||||||
|
error=""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _str_replace(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
old_str: str | None,
|
||||||
|
new_str: str | None
|
||||||
|
) -> CLIResult:
|
||||||
|
"""Replace a unique occurrence of old_str with new_str."""
|
||||||
|
if old_str is None:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error="Error: old_str is required for str_replace command"
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_str is None:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error="Error: new_str is required for str_replace command"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: file not found: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Check for unique match
|
||||||
|
count = content.count(old_str)
|
||||||
|
if count == 0:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: old_str not found in file: {old_str!r}"
|
||||||
|
)
|
||||||
|
elif count > 1:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: old_str must match exactly once, found {count} matches"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform replacement
|
||||||
|
new_content = content.replace(old_str, new_str)
|
||||||
|
path.write_text(new_content)
|
||||||
|
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=0,
|
||||||
|
output=f"Replaced 1 occurrence in: {path}",
|
||||||
|
error=""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _insert(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
insert_line: int | None,
|
||||||
|
new_str: str | None
|
||||||
|
) -> CLIResult:
|
||||||
|
"""Insert new_str at the specified line number."""
|
||||||
|
if insert_line is None:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error="Error: insert_line is required for insert command"
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_str is None:
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error="Error: new_str is required for insert command"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: file not found: {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = path.read_text()
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
|
||||||
|
# Validate line number
|
||||||
|
if insert_line < 0 or insert_line > len(lines):
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=1,
|
||||||
|
output="",
|
||||||
|
error=f"Error: insert_line {insert_line} out of range [0, {len(lines)}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert the new string
|
||||||
|
lines.insert(insert_line, new_str)
|
||||||
|
new_content = "".join(lines)
|
||||||
|
path.write_text(new_content)
|
||||||
|
|
||||||
|
return CLIResult(
|
||||||
|
exit_code=0,
|
||||||
|
output=f"Inserted text at line {insert_line} in: {path}",
|
||||||
|
error=""
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_params(self) -> dict[str, Any]:
|
||||||
|
"""Convert to Anthropic API tool parameter format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tool definition for Anthropic API with text_editor_20250728 type
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"type": self.api_type,
|
||||||
|
"name": self.name,
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Tests for EditTool20250728."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.agent.tools.anthropic.edit import EditTool20250728
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def edit_tool():
|
||||||
|
"""Create an EditTool20250728 instance."""
|
||||||
|
return EditTool20250728()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_file(tmp_path):
|
||||||
|
"""Create a temporary file with some content."""
|
||||||
|
file_path = tmp_path / "test.txt"
|
||||||
|
file_path.write_text("line 1\nline 2\nline 3\n")
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_view_command(edit_tool, temp_file):
|
||||||
|
"""Test viewing a file with line numbers."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="view",
|
||||||
|
path=str(temp_file)
|
||||||
|
)
|
||||||
|
assert result.output is not None
|
||||||
|
assert "1|line 1" in result.output
|
||||||
|
assert "2|line 2" in result.output
|
||||||
|
assert "3|line 3" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_command(edit_tool, tmp_path):
|
||||||
|
"""Test creating a new file."""
|
||||||
|
new_file = tmp_path / "new.txt"
|
||||||
|
result = await edit_tool(
|
||||||
|
command="create",
|
||||||
|
path=str(new_file),
|
||||||
|
file_text="Hello\nWorld\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert new_file.exists()
|
||||||
|
assert new_file.read_text() == "Hello\nWorld\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_str_replace_command(edit_tool, temp_file):
|
||||||
|
"""Test replacing a unique string."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="str_replace",
|
||||||
|
path=str(temp_file),
|
||||||
|
old_str="line 2",
|
||||||
|
new_str="LINE TWO"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = temp_file.read_text()
|
||||||
|
assert "LINE TWO" in content
|
||||||
|
assert "line 2" not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_str_replace_non_unique(edit_tool, temp_file):
|
||||||
|
"""Test that str_replace fails on non-unique match."""
|
||||||
|
# Write content with duplicate "line"
|
||||||
|
temp_file.write_text("line 1\nline 2\nline 3\n")
|
||||||
|
result = await edit_tool(
|
||||||
|
command="str_replace",
|
||||||
|
path=str(temp_file),
|
||||||
|
old_str="line", # This appears 3 times
|
||||||
|
new_str="LINE"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "must match exactly once" in result.error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_insert_command(edit_tool, temp_file):
|
||||||
|
"""Test inserting text at a specific line."""
|
||||||
|
result = await edit_tool(
|
||||||
|
command="insert",
|
||||||
|
path=str(temp_file),
|
||||||
|
insert_line=1,
|
||||||
|
new_str="inserted line\n"
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
content = temp_file.read_text()
|
||||||
|
lines = content.splitlines()
|
||||||
|
assert lines[1] == "inserted line"
|
||||||
Reference in New Issue
Block a user