From 0cd089894bed9effc988a7c619dbed535396227d Mon Sep 17 00:00:00 2001 From: Eric Phillips Date: Sat, 21 Feb 2026 14:51:39 -0700 Subject: [PATCH] read and write tools, moved schemas to tool/__init__ --- agent/tools.py | 27 +++++----------- tests/test_files.py | 46 ++++++++++++++++++++++++++ tools/__init__.py | 17 ++++++++++ tools/bash.py | 16 ++++++++++ tools/files.py | 78 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 tests/test_files.py create mode 100644 tools/__init__.py create mode 100644 tools/files.py diff --git a/agent/tools.py b/agent/tools.py index 5885259..383e293 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -1,27 +1,16 @@ -from tools.bash import bash - -TOOL_SCHEMAS = [ - { - "name": "bash", - "description": "Execute a bash command in the isolated sandbox environment. Use this to run shell commands, install packages, run scripts, etc.", - "input_schema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute (e.g., 'ls -la', 'python script.py', 'pip install requests')", - } - }, - "required": ["command"], - }, - } -] +from tools import TOOL_SCHEMAS, bash, read_file, write_file -async def dispatch_tool(tool_name: str, tool_input: dict, sandbox) -> str: +async def dispatch_tool(tool_name: str, tool_input: dict, sandbox): """Route tool calls to implementations.""" if tool_name == "bash": return await bash(command=tool_input["command"], sandbox=sandbox) + elif tool_name == "read_file": + return await read_file(tool_input["filepath"], sandbox=sandbox) + elif tool_name == "write_file": + return await write_file( + tool_input["filepath"], tool_input["content"], sandbox=sandbox + ) return f"Unknown tool: {tool_name}" diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..c74aab1 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,46 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest + +from tools.files import read_file, write_file + + +@pytest.mark.unit +async def test_read_file_with_no_sandbox(): + """Test read_file handles missing sandbox.""" + result = await read_file("test.txt", sandbox=None) + assert "error" in result.lower() + + +@pytest.mark.integration +async def test_real_write_then_read(): + """Integration: Write file, then read it back.""" + from sandbox.session import PodmanSandbox + + async with PodmanSandbox() as sb: + # Write + result = await write_file("test.txt", "Hello, world!", sb) + assert "✓" in result + + # Read back + content = await read_file("test.txt", sb) + assert "Hello, world!" in content + + # Cleanup + await asyncio.to_thread(sb.run, "rm test.txt") + + +@pytest.mark.integration +async def test_real_write_creates_directories(): + """Integration: write_file creates parent directories.""" + from sandbox.session import PodmanSandbox + + async with PodmanSandbox() as sb: + result = await write_file("dir1/dir2/test.txt", "nested", sb) + assert "✓" in result + + # Verify file exists + ls_result = await asyncio.to_thread(sb.run, "ls -R") + assert "dir1" in ls_result + assert "dir2" in ls_result diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..1a531b9 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,17 @@ +# tools/__init__.py +from tools.files import READ_FILE_SCHEMA, WRITE_FILE_SCHEMA, read_file, write_file + +from tools.bash import BASH_SCHEMA, bash + +__all__ = [ + "bash", + "read_file", + "write_file", + "TOOL_SCHEMAS", +] + +TOOL_SCHEMAS = [ + BASH_SCHEMA, + READ_FILE_SCHEMA, + WRITE_FILE_SCHEMA, +] diff --git a/tools/bash.py b/tools/bash.py index d0eee73..e9e8c58 100644 --- a/tools/bash.py +++ b/tools/bash.py @@ -25,3 +25,19 @@ async def bash(command: str, sandbox=None) -> str: except Exception as e: return f"Error: Unexpected failure: {e}" + + +BASH_SCHEMA = { + "name": "bash", + "description": "Execute a bash command in the isolated sandbox environment. Use this to run shell commands, install packages, run scripts, etc.", + "input_schema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute (e.g., 'ls -la', 'python script.py', 'pip install requests')", + } + }, + "required": ["command"], + }, +} diff --git a/tools/files.py b/tools/files.py new file mode 100644 index 0000000..608c6a1 --- /dev/null +++ b/tools/files.py @@ -0,0 +1,78 @@ +import asyncio +from pathlib import Path + + +async def read_file(filepath: str, sandbox=None) -> str: + """Read a file from workspace.""" + if sandbox is None: + return "Error: No sandbox available" + + try: + result = await asyncio.to_thread(sandbox.run, f"cat {filepath}") + return result + except Exception as e: + return f"Error reading file: {e}" + + +async def write_file(filepath: str, content: str, sandbox=None) -> str: + """Write content to a file.""" + if sandbox is None: + return "Error: No sandbox available" + + # Validate path + if filepath.startswith("/") or ".." in filepath: + return f"Error: Invalid path '{filepath}'. Use relative paths within workspace." + + try: + # Escape content for shell + escaped = content.replace("'", "'\\''") + + # Create parent dirs if needed + parent = str(Path(filepath).parent) + if parent and parent != ".": + await asyncio.to_thread(sandbox.run, f"mkdir -p {parent}") + + # Write file + await asyncio.to_thread( + sandbox.run, f"cat > {filepath} << 'EOF'\n{content}\nEOF" + ) + + return f"✓ Wrote {filepath}" + except Exception as e: + return f"Error writing file: {e}" + + +# Schemas +READ_FILE_SCHEMA = { + "name": "read_file", + "description": "Read the contents of a file from the workspace", + "input_schema": { + "type": "object", + "properties": { + "filepath": { + "type": "string", + "description": "Path to file relative to workspace (e.g., 'app.py', 'src/utils.py')", + } + }, + "required": ["filepath"], + }, +} + +WRITE_FILE_SCHEMA = { + "name": "write_file", + "description": "Write content to a file in the workspace. Creates parent directories if needed.", + "input_schema": { + "type": "object", + "properties": { + "filepath": { + "type": "string", + "description": "Path to file relative to workspace (e.g., 'app.py', 'data/config.json')", + }, + "content": { + "type": "string", + "description": "Content to write to the file", + }, + }, + "required": ["filepath", "content"], + }, +}