feat: Initial library extraction from PIN LLM benchmark
DockerSandbox + LangChain file/shell tools extracted into a standalone package. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
6
docker_agent_sandbox/__init__.py
Normal file
6
docker_agent_sandbox/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""docker_agent_sandbox – Docker sandbox + LangChain tools for LLM agents."""
|
||||
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
from docker_agent_sandbox.tools import make_bash_tool, make_file_ops_tools
|
||||
|
||||
__all__ = ["DockerSandbox", "make_bash_tool", "make_file_ops_tools"]
|
||||
214
docker_agent_sandbox/sandbox.py
Normal file
214
docker_agent_sandbox/sandbox.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""sandbox.py – Docker container lifecycle and execution environment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import io
|
||||
import socket
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import docker.models.containers
|
||||
|
||||
|
||||
class DockerSandbox:
|
||||
"""
|
||||
Manages a single long-running Docker container used as the bash execution
|
||||
environment for an LLM agent.
|
||||
|
||||
The sandbox directory is bind-mounted at *container_workdir* inside the
|
||||
container (default ``/workspace``), giving the model a stable, short path
|
||||
regardless of where the sandbox lives on the host.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox_dir: str,
|
||||
container_name: str,
|
||||
container_workdir: str = "/workspace",
|
||||
pin_mcp_port: int = 8080,
|
||||
image: str = "docker-agent-sandbox",
|
||||
dockerfile_dir: str | None = None,
|
||||
) -> None:
|
||||
self.sandbox_dir = sandbox_dir
|
||||
self.container_name = container_name
|
||||
self.container_workdir = container_workdir
|
||||
self.pin_mcp_port = pin_mcp_port
|
||||
self.mcp_url: str = ""
|
||||
self._image = image
|
||||
self._dockerfile_dir = dockerfile_dir
|
||||
self._client: docker.DockerClient = docker.from_env()
|
||||
self._container: docker.models.containers.Container | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_image_if_missing(self) -> None:
|
||||
"""Build the Docker image if it is not already present locally."""
|
||||
try:
|
||||
self._client.images.get(self._image)
|
||||
logger.info("Image {!r} already present, skipping build.", self._image)
|
||||
return
|
||||
except docker.errors.ImageNotFound:
|
||||
pass
|
||||
|
||||
if self._dockerfile_dir is None:
|
||||
raise ValueError(
|
||||
"dockerfile_dir must be provided to build the image"
|
||||
)
|
||||
|
||||
logger.info("Building image {!r} from {} …", self._image, self._dockerfile_dir)
|
||||
_, logs = self._client.images.build(
|
||||
path=self._dockerfile_dir,
|
||||
tag=self._image,
|
||||
rm=True,
|
||||
)
|
||||
for entry in logs:
|
||||
line = entry.get("stream", "").rstrip()
|
||||
if line:
|
||||
logger.debug(" {}", line)
|
||||
|
||||
logger.success("Image {!r} built successfully.", self._image)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Start the sandbox container.
|
||||
|
||||
Any existing container with the same name is removed first so that
|
||||
re-running the agent always starts from a clean state.
|
||||
"""
|
||||
# Remove any leftover container from a previous run.
|
||||
try:
|
||||
old = self._client.containers.get(self.container_name)
|
||||
old.remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
|
||||
self._container = self._client.containers.run(
|
||||
self._image,
|
||||
name=self.container_name,
|
||||
detach=True,
|
||||
volumes={
|
||||
self.sandbox_dir: {"bind": self.container_workdir, "mode": "rw,Z"}
|
||||
},
|
||||
working_dir=self.container_workdir,
|
||||
environment={
|
||||
"CONTAINER_WORKSPACE": self.container_workdir,
|
||||
"PIN_MCP_PORT": str(self.pin_mcp_port),
|
||||
},
|
||||
# Expose pin-mcp port; Docker assigns a random host port.
|
||||
ports={f"{self.pin_mcp_port}/tcp": None},
|
||||
# No outbound network needed; all tools are pre-installed.
|
||||
network_mode="bridge",
|
||||
# Minimal capability set; SYS_PTRACE is required for ltrace/strace/gdb.
|
||||
cap_drop=["ALL"],
|
||||
cap_add=["SYS_PTRACE"],
|
||||
security_opt=["no-new-privileges"],
|
||||
)
|
||||
|
||||
# Resolve the host port Docker assigned and wait for pin-mcp to be ready.
|
||||
self._container.reload()
|
||||
host_port = self._container.ports[f"{self.pin_mcp_port}/tcp"][0]["HostPort"]
|
||||
self.mcp_url = f"http://localhost:{host_port}/mcp"
|
||||
|
||||
self._wait_for_mcp()
|
||||
logger.info(
|
||||
"Container {!r} started (id={}), pin-mcp at {}.",
|
||||
self.container_name,
|
||||
self._container.short_id,
|
||||
self.mcp_url,
|
||||
)
|
||||
|
||||
def _wait_for_mcp(self, timeout: int = 30) -> None:
|
||||
"""Block until pin-mcp's TCP port accepts connections, or raise on timeout."""
|
||||
host_port = int(self.mcp_url.split(":")[-1].split("/")[0])
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("localhost", host_port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(
|
||||
f"pin-mcp did not become ready on port {host_port} within {timeout}s"
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Remove the sandbox container."""
|
||||
if self._container is not None:
|
||||
self._container.remove(force=True)
|
||||
logger.info("Container {!r} stopped.", self.container_name)
|
||||
self._container = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File I/O
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_file(self, path: str, content: str) -> None:
|
||||
"""
|
||||
Write *content* to *path* inside the container using ``put_archive``.
|
||||
|
||||
Using the archive API avoids all shell-escaping concerns; any text
|
||||
(including content with quotes, backslashes, or null bytes) is
|
||||
transferred safely as a tar stream. Parent directories are created
|
||||
automatically via a preceding ``mkdir -p``.
|
||||
"""
|
||||
if self._container is None:
|
||||
raise RuntimeError("Sandbox container is not running.")
|
||||
|
||||
p = Path(path)
|
||||
encoded = content.encode("utf-8")
|
||||
|
||||
# Ensure the parent directory exists inside the container.
|
||||
self._container.exec_run(["mkdir", "-p", str(p.parent)])
|
||||
|
||||
# Pack the file into an in-memory tar archive and push it in.
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
info = tarfile.TarInfo(name=p.name)
|
||||
info.size = len(encoded)
|
||||
info.mode = 0o644
|
||||
tar.addfile(info, io.BytesIO(encoded))
|
||||
buf.seek(0)
|
||||
self._container.put_archive(str(p.parent), buf)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def exec(self, command: str, timeout: int = 120) -> tuple[int, str]:
|
||||
"""
|
||||
Run *command* inside the container via ``exec_run``.
|
||||
|
||||
Returns ``(exit_code, combined_stdout_stderr)``.
|
||||
The call is wrapped in a thread so the *timeout* is enforced without
|
||||
modifying the command string.
|
||||
"""
|
||||
if self._container is None:
|
||||
return 1, "Sandbox container is not running."
|
||||
|
||||
def _run() -> tuple[int, bytes]:
|
||||
exit_code, output = self._container.exec_run(
|
||||
["bash", "-c", command],
|
||||
workdir=self.container_workdir,
|
||||
demux=False,
|
||||
)
|
||||
return exit_code, output or b""
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(_run)
|
||||
try:
|
||||
exit_code, raw = future.result(timeout=timeout)
|
||||
return exit_code, raw.decode("utf-8", errors="replace")
|
||||
except concurrent.futures.TimeoutError:
|
||||
return 124, f"Command timed out after {timeout}s"
|
||||
except Exception as exc:
|
||||
return 1, f"Error running command in container: {exc}"
|
||||
6
docker_agent_sandbox/tools/__init__.py
Normal file
6
docker_agent_sandbox/tools/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""tools – LangChain tools that operate inside a DockerSandbox."""
|
||||
|
||||
from docker_agent_sandbox.tools.bash import make_bash_tool
|
||||
from docker_agent_sandbox.tools.file_ops import make_file_ops_tools
|
||||
|
||||
__all__ = ["make_bash_tool", "make_file_ops_tools"]
|
||||
25
docker_agent_sandbox/tools/_utils.py
Normal file
25
docker_agent_sandbox/tools/_utils.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""_utils.py – shared helpers for file-ops tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
|
||||
_MAX_OUTPUT_LINES = 200
|
||||
_MAX_OUTPUT_CHARS = 20_000
|
||||
_TRUNCATION_NOTICE = "\n... [output truncated] ..."
|
||||
|
||||
|
||||
def truncate_output(output: str) -> str:
|
||||
"""Truncate *output* to avoid hitting token limits."""
|
||||
lines = output.splitlines(keepends=True)
|
||||
if len(lines) > _MAX_OUTPUT_LINES:
|
||||
output = "".join(lines[:_MAX_OUTPUT_LINES]) + _TRUNCATION_NOTICE
|
||||
if len(output) > _MAX_OUTPUT_CHARS:
|
||||
output = output[:_MAX_OUTPUT_CHARS] + _TRUNCATION_NOTICE
|
||||
return output
|
||||
|
||||
|
||||
def _parent(path: str) -> str:
|
||||
"""Return the parent directory of *path* (best-effort, no I/O)."""
|
||||
parent = posixpath.dirname(path.rstrip("/"))
|
||||
return parent or "."
|
||||
38
docker_agent_sandbox/tools/bash.py
Normal file
38
docker_agent_sandbox/tools/bash.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""bash.py – tool for executing shell commands inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
from docker_agent_sandbox.tools._utils import truncate_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_bash_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""
|
||||
Return a bash tool that executes commands inside the Docker sandbox container.
|
||||
|
||||
The model's working directory is the sandbox root; all paths it uses are
|
||||
identical on the host (via the bind mount) and inside the container.
|
||||
"""
|
||||
|
||||
@tool
|
||||
def bash(command: str, timeout: int = 120) -> str:
|
||||
"""
|
||||
Execute a shell command in the sandbox container.
|
||||
|
||||
Returns EXIT:<code> followed by combined stdout+stderr.
|
||||
Large outputs are truncated to stay within token limits.
|
||||
Use for: running the target binary, processing PIN output,
|
||||
compiling plugins, or any other shell operation during analysis.
|
||||
"""
|
||||
logger.debug("Running inside sandbox: {}", command)
|
||||
exit_code, output = sandbox.exec(command, timeout=timeout)
|
||||
return f"EXIT:{exit_code}\n{truncate_output(output)}"
|
||||
|
||||
return bash
|
||||
37
docker_agent_sandbox/tools/copy_file.py
Normal file
37
docker_agent_sandbox/tools/copy_file.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""copy_file.py – tool for copying files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
from docker_agent_sandbox.tools._utils import _parent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_copy_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a copy_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def copy_file(src: str, dst: str) -> str:
|
||||
"""
|
||||
Copy a file from *src* to *dst*.
|
||||
|
||||
Parent directories of *dst* are created automatically.
|
||||
Returns a confirmation message or an error.
|
||||
"""
|
||||
logger.debug("Copying file inside sandbox: {!r} -> {!r}", src, dst)
|
||||
mkdir_cmd = f"mkdir -p -- {quote(_parent(dst))}"
|
||||
exit_code, output = sandbox.exec(
|
||||
f"{mkdir_cmd} && cp -- {quote(src)} {quote(dst)}"
|
||||
)
|
||||
if exit_code != 0:
|
||||
return f"[ERROR copying {src!r} to {dst!r}] {output.strip()}"
|
||||
return f"[OK] Copied {src} -> {dst}"
|
||||
|
||||
return copy_file
|
||||
33
docker_agent_sandbox/tools/delete_file.py
Normal file
33
docker_agent_sandbox/tools/delete_file.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""delete_file.py – tool for deleting files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_delete_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a delete_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def delete_file(path: str) -> str:
|
||||
"""
|
||||
Delete a file or empty directory at *path*.
|
||||
|
||||
Use ``delete_file`` only for files or empty directories. To remove a
|
||||
directory tree use ``move_file`` to archive it first, or call this tool
|
||||
repeatedly. Returns a confirmation message or an error.
|
||||
"""
|
||||
logger.debug("Deleting file inside sandbox: {}", path)
|
||||
exit_code, output = sandbox.exec(f"rm -d -- {quote(path)}")
|
||||
if exit_code != 0:
|
||||
return f"[ERROR deleting {path!r}] {output.strip()}"
|
||||
return f"[OK] Deleted {path}"
|
||||
|
||||
return delete_file
|
||||
65
docker_agent_sandbox/tools/edit_file.py
Normal file
65
docker_agent_sandbox/tools/edit_file.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""edit_file.py – tool for str_replace editing of files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_edit_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return an edit_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def edit_file(path: str, old_str: str, new_str: str) -> str:
|
||||
"""
|
||||
Replace the first exact occurrence of *old_str* with *new_str* in *path*.
|
||||
|
||||
This is the standard ``str_replace`` editing primitive: read the file,
|
||||
find the unique snippet you want to change, and supply the replacement.
|
||||
|
||||
Rules:
|
||||
- *old_str* must match **exactly** (including whitespace and indentation).
|
||||
- *old_str* must appear **at least once**; the tool returns an error if it
|
||||
is not found.
|
||||
- If *old_str* appears more than once the tool refuses and asks you to
|
||||
provide more surrounding context to make it unique.
|
||||
- To insert text without removing anything, set *old_str* to a line that
|
||||
will remain and include it verbatim in *new_str* (i.e. keep the anchor
|
||||
line and add your new lines around it).
|
||||
- To delete a block, set *new_str* to an empty string ``""``.
|
||||
|
||||
Returns a confirmation with the number of lines affected, or an error.
|
||||
"""
|
||||
logger.debug("Editing file inside sandbox: {!r}", path)
|
||||
exit_code, content = sandbox.exec(f"cat -- {quote(path)}")
|
||||
if exit_code != 0:
|
||||
return f"[ERROR reading {path!r} for edit] {content.strip()}"
|
||||
|
||||
count = content.count(old_str)
|
||||
if count == 0:
|
||||
return (
|
||||
f"[ERROR] old_str not found in {path!r}. "
|
||||
"Check that whitespace and indentation match exactly."
|
||||
)
|
||||
if count > 1:
|
||||
return (
|
||||
f"[ERROR] old_str appears {count} times in {path!r}. "
|
||||
"Provide more surrounding context to make it unique."
|
||||
)
|
||||
|
||||
new_content = content.replace(old_str, new_str, 1)
|
||||
old_lines = old_str.count("\n") + 1
|
||||
new_lines = new_str.count("\n") + 1 if new_str else 0
|
||||
try:
|
||||
sandbox.write_file(path, new_content)
|
||||
except Exception as exc:
|
||||
return f"[ERROR writing {path!r} after edit] {exc}"
|
||||
return f"[OK] Replaced {old_lines} line(s) with {new_lines} line(s) in {path}"
|
||||
|
||||
return edit_file
|
||||
43
docker_agent_sandbox/tools/file_ops.py
Normal file
43
docker_agent_sandbox/tools/file_ops.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""file_ops.py – assembles all file-operation tools into a single list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from docker_agent_sandbox.tools.copy_file import make_copy_file_tool
|
||||
from docker_agent_sandbox.tools.delete_file import make_delete_file_tool
|
||||
from docker_agent_sandbox.tools.edit_file import make_edit_file_tool
|
||||
from docker_agent_sandbox.tools.grep import make_grep_tool
|
||||
from docker_agent_sandbox.tools.list_dir import make_list_dir_tool
|
||||
from docker_agent_sandbox.tools.make_dir import make_make_dir_tool
|
||||
from docker_agent_sandbox.tools.move_file import make_move_file_tool
|
||||
from docker_agent_sandbox.tools.read_file import make_read_file_tool
|
||||
from docker_agent_sandbox.tools.search_files import make_search_files_tool
|
||||
from docker_agent_sandbox.tools.write_file import make_write_file_tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_file_ops_tools(sandbox: "DockerSandbox") -> list[BaseTool]:
|
||||
"""
|
||||
Return file-operation tools bound to *sandbox*.
|
||||
|
||||
All paths are interpreted by the filesystem the model is working in — it
|
||||
can use any absolute path (e.g. ``/tmp/re-agent/output.csv``) or a relative
|
||||
one (resolved against the working directory).
|
||||
"""
|
||||
return [
|
||||
make_read_file_tool(sandbox),
|
||||
make_write_file_tool(sandbox),
|
||||
make_edit_file_tool(sandbox),
|
||||
make_list_dir_tool(sandbox),
|
||||
make_delete_file_tool(sandbox),
|
||||
make_move_file_tool(sandbox),
|
||||
make_copy_file_tool(sandbox),
|
||||
make_make_dir_tool(sandbox),
|
||||
make_search_files_tool(sandbox),
|
||||
make_grep_tool(sandbox),
|
||||
]
|
||||
45
docker_agent_sandbox/tools/grep.py
Normal file
45
docker_agent_sandbox/tools/grep.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""grep.py – tool for searching file contents inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_grep_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a grep tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def grep(pattern: str, path: str, recursive: bool = False) -> str:
|
||||
"""
|
||||
Search for *pattern* (extended regex) in *path*.
|
||||
|
||||
*path* can be a file or a directory; when *path* is a directory,
|
||||
*recursive* must be ``True``. Returns matching lines with file names
|
||||
and line numbers, or an error message.
|
||||
|
||||
Useful for locating strings, symbol names, or byte sequences in
|
||||
binaries and text files.
|
||||
"""
|
||||
logger.debug(
|
||||
"Grepping inside sandbox: pattern={!r} path={!r} recursive={}",
|
||||
pattern,
|
||||
path,
|
||||
recursive,
|
||||
)
|
||||
flags = "-rn" if recursive else "-n"
|
||||
exit_code, output = sandbox.exec(
|
||||
f"grep -E {flags} -- {quote(pattern)} {quote(path)} 2>&1"
|
||||
)
|
||||
# grep exits 1 when no matches — that is not an error
|
||||
if exit_code not in (0, 1):
|
||||
return f"[ERROR grepping {path!r}] {output.strip()}"
|
||||
return output.strip() or "[no matches found]"
|
||||
|
||||
return grep
|
||||
32
docker_agent_sandbox/tools/list_dir.py
Normal file
32
docker_agent_sandbox/tools/list_dir.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""list_dir.py – tool for listing directory contents inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_list_dir_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a list_dir tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def list_dir(path: str = ".") -> str:
|
||||
"""
|
||||
List the contents of a directory at *path*.
|
||||
|
||||
Returns ``ls -lA`` output,
|
||||
or an error message if the path does not exist or is not a directory.
|
||||
"""
|
||||
logger.debug("Listing files inside sandbox: {}", path)
|
||||
exit_code, output = sandbox.exec(f"ls -lA -- {quote(path)}")
|
||||
if exit_code != 0:
|
||||
return f"[ERROR listing {path!r}] {output.strip()}"
|
||||
return output
|
||||
|
||||
return list_dir
|
||||
32
docker_agent_sandbox/tools/make_dir.py
Normal file
32
docker_agent_sandbox/tools/make_dir.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""make_dir.py – tool for creating directories inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_make_dir_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a make_dir tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def make_dir(path: str) -> str:
|
||||
"""
|
||||
Create directory *path* (and all missing parents).
|
||||
|
||||
Succeeds silently if the directory already exists.
|
||||
Returns a confirmation message or an error.
|
||||
"""
|
||||
logger.debug("Creating directory inside sandbox: {}", path)
|
||||
exit_code, output = sandbox.exec(f"mkdir -p -- {quote(path)}")
|
||||
if exit_code != 0:
|
||||
return f"[ERROR creating directory {path!r}] {output.strip()}"
|
||||
return f"[OK] Directory exists: {path}"
|
||||
|
||||
return make_dir
|
||||
37
docker_agent_sandbox/tools/move_file.py
Normal file
37
docker_agent_sandbox/tools/move_file.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""move_file.py – tool for moving/renaming files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
from docker_agent_sandbox.tools._utils import _parent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_move_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a move_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def move_file(src: str, dst: str) -> str:
|
||||
"""
|
||||
Move or rename a file from *src* to *dst*.
|
||||
|
||||
Parent directories of *dst* are created automatically.
|
||||
Returns a confirmation message or an error.
|
||||
"""
|
||||
logger.debug("Moving file inside sandbox: {!r} -> {!r}", src, dst)
|
||||
mkdir_cmd = f"mkdir -p -- {quote(_parent(dst))}"
|
||||
exit_code, output = sandbox.exec(
|
||||
f"{mkdir_cmd} && mv -- {quote(src)} {quote(dst)}"
|
||||
)
|
||||
if exit_code != 0:
|
||||
return f"[ERROR moving {src!r} to {dst!r}] {output.strip()}"
|
||||
return f"[OK] Moved {src} -> {dst}"
|
||||
|
||||
return move_file
|
||||
60
docker_agent_sandbox/tools/read_file.py
Normal file
60
docker_agent_sandbox/tools/read_file.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""read_file.py – tool for reading files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_read_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a read_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def read_file(path: str, offset: int = 0, length: int = 5000) -> str:
|
||||
"""
|
||||
Read a file at *path*.
|
||||
|
||||
*path* can be absolute (``/tmp/re-agent/result.csv``) or relative to the
|
||||
working directory.
|
||||
|
||||
*offset* is the number of bytes to skip from the start of the file.
|
||||
*length* is the maximum number of bytes to return. If the file is
|
||||
longer than ``offset + length``, the
|
||||
output is trimmed and a summary line is appended showing how many
|
||||
bytes were omitted.
|
||||
|
||||
Returns the (possibly trimmed) file contents as text, or an error message.
|
||||
"""
|
||||
logger.debug(
|
||||
"Reading file inside sandbox: {} offset={} length={}", path, offset, length
|
||||
)
|
||||
exit_code, wc_out = sandbox.exec(f"wc -c -- {quote(path)}")
|
||||
if exit_code != 0:
|
||||
return f"[ERROR reading {path!r}] {wc_out.strip()}"
|
||||
try:
|
||||
total = int(wc_out.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
return f"[ERROR parsing file size for {path!r}] {wc_out.strip()}"
|
||||
|
||||
exit_code, chunk = sandbox.exec(
|
||||
f"dd if={quote(path)} iflag=skip_bytes,count_bytes"
|
||||
f" skip={offset} count={length} 2>/dev/null"
|
||||
)
|
||||
if exit_code != 0:
|
||||
return f"[ERROR reading {path!r}] {chunk.strip()}"
|
||||
|
||||
suffix = ""
|
||||
if offset + length < total:
|
||||
remaining = total - (offset + length)
|
||||
suffix = f"\n[... {remaining} more bytes not shown (total {total} bytes). Use offset/length to read further.]"
|
||||
elif offset > 0 or total > length:
|
||||
suffix = f"\n[File total: {total} bytes, showing {len(chunk)} chars from offset {offset}.]"
|
||||
return chunk + suffix
|
||||
|
||||
return read_file
|
||||
40
docker_agent_sandbox/tools/search_files.py
Normal file
40
docker_agent_sandbox/tools/search_files.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""search_files.py – tool for finding files by name pattern inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shlex import quote
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_search_files_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a search_files tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def search_files(pattern: str, directory: str = ".") -> str:
|
||||
"""
|
||||
Find files whose names match *pattern* (shell glob) under *directory*.
|
||||
|
||||
Examples::
|
||||
|
||||
search_files("*.so", "/tmp/re-agent")
|
||||
search_files("main", "/usr/bin")
|
||||
|
||||
Returns a newline-separated list of matching paths, or an error message.
|
||||
"""
|
||||
logger.debug(
|
||||
"Searching files inside sandbox: pattern={!r} dir={!r}", pattern, directory
|
||||
)
|
||||
exit_code, output = sandbox.exec(
|
||||
f"find {quote(directory)} -name {quote(pattern)} -print 2>/dev/null"
|
||||
)
|
||||
if exit_code != 0:
|
||||
return f"[ERROR searching {directory!r}] {output.strip()}"
|
||||
return output.strip() or "[no matches found]"
|
||||
|
||||
return search_files
|
||||
32
docker_agent_sandbox/tools/write_file.py
Normal file
32
docker_agent_sandbox/tools/write_file.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""write_file.py – tool for writing files inside the sandbox."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docker_agent_sandbox.sandbox import DockerSandbox
|
||||
|
||||
|
||||
def make_write_file_tool(sandbox: "DockerSandbox") -> BaseTool:
|
||||
"""Return a write_file tool bound to *sandbox*."""
|
||||
|
||||
@tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""
|
||||
Write *content* to *path*.
|
||||
|
||||
*path* can be absolute or relative. Parent directories are created
|
||||
automatically. Returns a confirmation message or an error.
|
||||
"""
|
||||
try:
|
||||
logger.debug("Writing file inside sandbox: {}", path)
|
||||
sandbox.write_file(path, content)
|
||||
except Exception as exc:
|
||||
return f"[ERROR writing {path!r}] {exc}"
|
||||
return f"[OK] Written {len(content.encode())} bytes to {path}"
|
||||
|
||||
return write_file
|
||||
13
pyproject.toml
Normal file
13
pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "docker-agent-sandbox"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"docker",
|
||||
"langchain-core",
|
||||
"loguru",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
Reference in New Issue
Block a user