2026-02-05 03:49:46 -08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""File Tools Module - LLM agent file manipulation tools."""
|
|
|
|
|
|
|
|
|
|
import json
|
2026-02-21 03:11:11 -08:00
|
|
|
import logging
|
2026-02-08 05:00:47 +00:00
|
|
|
import os
|
2026-02-05 03:49:46 -08:00
|
|
|
import threading
|
|
|
|
|
from typing import Optional
|
|
|
|
|
from tools.file_operations import ShellFileOperations
|
|
|
|
|
|
2026-02-21 03:11:11 -08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-05 03:49:46 -08:00
|
|
|
_file_ops_lock = threading.Lock()
|
|
|
|
|
_file_ops_cache: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_file_ops(task_id: str = "default") -> ShellFileOperations:
|
2026-02-08 05:00:47 +00:00
|
|
|
"""Get or create ShellFileOperations for a terminal environment.
|
2026-02-16 19:37:40 -08:00
|
|
|
|
2026-02-08 05:00:47 +00:00
|
|
|
Respects the TERMINAL_ENV setting -- if the task_id doesn't have an
|
|
|
|
|
environment yet, creates one using the configured backend (local, docker,
|
|
|
|
|
modal, etc.) rather than always defaulting to local.
|
2026-02-16 19:37:40 -08:00
|
|
|
|
|
|
|
|
Thread-safe: uses the same per-task creation locks as terminal_tool to
|
|
|
|
|
prevent duplicate sandbox creation from concurrent tool calls.
|
2026-02-08 05:00:47 +00:00
|
|
|
"""
|
|
|
|
|
from tools.terminal_tool import (
|
|
|
|
|
_active_environments, _env_lock, _create_environment,
|
|
|
|
|
_get_env_config, _last_activity, _start_cleanup_thread,
|
|
|
|
|
_check_disk_usage_warning,
|
2026-02-16 19:37:40 -08:00
|
|
|
_creation_locks, _creation_locks_lock,
|
2026-02-08 05:00:47 +00:00
|
|
|
)
|
|
|
|
|
import time
|
2026-02-16 19:37:40 -08:00
|
|
|
|
|
|
|
|
# Fast path: check cache -- but also verify the underlying environment
|
|
|
|
|
# is still alive (it may have been killed by the cleanup thread).
|
2026-02-05 03:49:46 -08:00
|
|
|
with _file_ops_lock:
|
2026-02-16 19:37:40 -08:00
|
|
|
cached = _file_ops_cache.get(task_id)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
with _env_lock:
|
|
|
|
|
if task_id in _active_environments:
|
|
|
|
|
_last_activity[task_id] = time.time()
|
|
|
|
|
return cached
|
|
|
|
|
else:
|
|
|
|
|
# Environment was cleaned up -- invalidate stale cache entry
|
|
|
|
|
with _file_ops_lock:
|
|
|
|
|
_file_ops_cache.pop(task_id, None)
|
|
|
|
|
|
|
|
|
|
# Need to ensure the environment exists before building file_ops.
|
|
|
|
|
# Acquire per-task lock so only one thread creates the sandbox.
|
|
|
|
|
with _creation_locks_lock:
|
|
|
|
|
if task_id not in _creation_locks:
|
|
|
|
|
_creation_locks[task_id] = threading.Lock()
|
|
|
|
|
task_lock = _creation_locks[task_id]
|
|
|
|
|
|
|
|
|
|
with task_lock:
|
|
|
|
|
# Double-check: another thread may have created it while we waited
|
|
|
|
|
with _env_lock:
|
|
|
|
|
if task_id in _active_environments:
|
|
|
|
|
_last_activity[task_id] = time.time()
|
|
|
|
|
terminal_env = _active_environments[task_id]
|
|
|
|
|
else:
|
|
|
|
|
terminal_env = None
|
|
|
|
|
|
|
|
|
|
if terminal_env is None:
|
|
|
|
|
from tools.terminal_tool import _task_env_overrides
|
|
|
|
|
|
|
|
|
|
config = _get_env_config()
|
|
|
|
|
env_type = config["env_type"]
|
|
|
|
|
overrides = _task_env_overrides.get(task_id, {})
|
|
|
|
|
|
|
|
|
|
if env_type == "docker":
|
|
|
|
|
image = overrides.get("docker_image") or config["docker_image"]
|
|
|
|
|
elif env_type == "singularity":
|
|
|
|
|
image = overrides.get("singularity_image") or config["singularity_image"]
|
|
|
|
|
elif env_type == "modal":
|
|
|
|
|
image = overrides.get("modal_image") or config["modal_image"]
|
|
|
|
|
else:
|
|
|
|
|
image = ""
|
|
|
|
|
|
|
|
|
|
cwd = overrides.get("cwd") or config["cwd"]
|
2026-02-21 03:11:11 -08:00
|
|
|
logger.info("Creating new %s environment for task %s...", env_type, task_id[:8])
|
2026-02-16 19:37:40 -08:00
|
|
|
|
|
|
|
|
terminal_env = _create_environment(
|
|
|
|
|
env_type=env_type,
|
|
|
|
|
image=image,
|
|
|
|
|
cwd=cwd,
|
|
|
|
|
timeout=config["timeout"],
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-12 05:37:14 +00:00
|
|
|
with _env_lock:
|
2026-02-16 19:37:40 -08:00
|
|
|
_active_environments[task_id] = terminal_env
|
|
|
|
|
_last_activity[task_id] = time.time()
|
|
|
|
|
|
|
|
|
|
_start_cleanup_thread()
|
2026-02-21 03:11:11 -08:00
|
|
|
logger.info("%s environment ready for task %s", env_type, task_id[:8])
|
2026-02-16 19:37:40 -08:00
|
|
|
|
|
|
|
|
# Build file_ops from the (guaranteed live) environment and cache it
|
2026-02-08 05:00:47 +00:00
|
|
|
file_ops = ShellFileOperations(terminal_env)
|
|
|
|
|
with _file_ops_lock:
|
2026-02-05 03:49:46 -08:00
|
|
|
_file_ops_cache[task_id] = file_ops
|
2026-02-08 05:00:47 +00:00
|
|
|
return file_ops
|
2026-02-05 03:49:46 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def clear_file_ops_cache(task_id: str = None):
|
|
|
|
|
"""Clear the file operations cache."""
|
|
|
|
|
with _file_ops_lock:
|
|
|
|
|
if task_id:
|
|
|
|
|
_file_ops_cache.pop(task_id, None)
|
|
|
|
|
else:
|
|
|
|
|
_file_ops_cache.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = "default") -> str:
|
|
|
|
|
"""Read a file with pagination and line numbers."""
|
|
|
|
|
try:
|
|
|
|
|
file_ops = _get_file_ops(task_id)
|
|
|
|
|
result = file_ops.read_file(path, offset, limit)
|
|
|
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_file_tool(path: str, content: str, task_id: str = "default") -> str:
|
|
|
|
|
"""Write content to a file."""
|
|
|
|
|
try:
|
|
|
|
|
file_ops = _get_file_ops(task_id)
|
|
|
|
|
result = file_ops.write_file(path, content)
|
|
|
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
|
except Exception as e:
|
2026-02-08 05:00:47 +00:00
|
|
|
print(f"[FileTools] write_file error: {type(e).__name__}: {e}", flush=True)
|
2026-02-05 03:49:46 -08:00
|
|
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
|
|
|
|
|
new_string: str = None, replace_all: bool = False, patch: str = None,
|
|
|
|
|
task_id: str = "default") -> str:
|
|
|
|
|
"""Patch a file using replace mode or V4A patch format."""
|
|
|
|
|
try:
|
|
|
|
|
file_ops = _get_file_ops(task_id)
|
|
|
|
|
|
|
|
|
|
if mode == "replace":
|
|
|
|
|
if not path:
|
|
|
|
|
return json.dumps({"error": "path required"})
|
|
|
|
|
if old_string is None or new_string is None:
|
|
|
|
|
return json.dumps({"error": "old_string and new_string required"})
|
|
|
|
|
result = file_ops.patch_replace(path, old_string, new_string, replace_all)
|
|
|
|
|
elif mode == "patch":
|
|
|
|
|
if not patch:
|
|
|
|
|
return json.dumps({"error": "patch content required"})
|
|
|
|
|
result = file_ops.patch_v4a(patch)
|
|
|
|
|
else:
|
|
|
|
|
return json.dumps({"error": f"Unknown mode: {mode}"})
|
|
|
|
|
|
|
|
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def search_tool(pattern: str, target: str = "content", path: str = ".",
|
|
|
|
|
file_glob: str = None, limit: int = 50, offset: int = 0,
|
|
|
|
|
output_mode: str = "content", context: int = 0,
|
|
|
|
|
task_id: str = "default") -> str:
|
|
|
|
|
"""Search for content or files."""
|
|
|
|
|
try:
|
|
|
|
|
file_ops = _get_file_ops(task_id)
|
|
|
|
|
result = file_ops.search(
|
|
|
|
|
pattern=pattern, path=path, target=target, file_glob=file_glob,
|
|
|
|
|
limit=limit, offset=offset, output_mode=output_mode, context=context
|
|
|
|
|
)
|
|
|
|
|
return json.dumps(result.to_dict(), ensure_ascii=False)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
FILE_TOOLS = [
|
|
|
|
|
{"name": "read_file", "function": read_file_tool},
|
|
|
|
|
{"name": "write_file", "function": write_file_tool},
|
|
|
|
|
{"name": "patch", "function": patch_tool},
|
2026-02-20 02:43:57 -08:00
|
|
|
{"name": "search_files", "function": search_tool}
|
2026-02-05 03:49:46 -08:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_file_tools():
|
|
|
|
|
"""Get the list of file tool definitions."""
|
|
|
|
|
return FILE_TOOLS
|