Rebase onto upstream (a4d95fd) #12

Closed
wylab wants to merge 144 commits from rebase-onto-upstream into main
4 changed files with 351 additions and 0 deletions
Showing only changes of commit d5f9cf7fc7 - Show all commits
@@ -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",
] ]
+2
View File
@@ -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):
+256
View File
@@ -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,
}
+91
View File
@@ -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"