mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-22 05:34:45 +08:00
feat: add sandbox and local impl
This commit is contained in:
8
backend/src/sandbox/__init__.py
Normal file
8
backend/src/sandbox/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .sandbox import Sandbox
|
||||||
|
from .sandbox_provider import SandboxProvider, get_sandbox_provider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Sandbox",
|
||||||
|
"SandboxProvider",
|
||||||
|
"get_sandbox_provider",
|
||||||
|
]
|
||||||
3
backend/src/sandbox/local/__init__.py
Normal file
3
backend/src/sandbox/local/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .local_sandbox_provider import LocalSandboxProvider
|
||||||
|
|
||||||
|
__all__ = ["LocalSandboxProvider"]
|
||||||
116
backend/src/sandbox/local/list_dir.py
Normal file
116
backend/src/sandbox/local/list_dir.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IGNORE_PATTERNS = [
|
||||||
|
# Version Control
|
||||||
|
".git",
|
||||||
|
".svn",
|
||||||
|
".hg",
|
||||||
|
".bzr",
|
||||||
|
# Dependencies
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
".env",
|
||||||
|
"env",
|
||||||
|
".tox",
|
||||||
|
".nox",
|
||||||
|
".eggs",
|
||||||
|
"*.egg-info",
|
||||||
|
"site-packages",
|
||||||
|
# Build outputs
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
".next",
|
||||||
|
".nuxt",
|
||||||
|
".output",
|
||||||
|
".turbo",
|
||||||
|
"target",
|
||||||
|
"out",
|
||||||
|
# IDE & Editor
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"*.swp",
|
||||||
|
"*.swo",
|
||||||
|
"*~",
|
||||||
|
".project",
|
||||||
|
".classpath",
|
||||||
|
".settings",
|
||||||
|
# OS generated
|
||||||
|
".DS_Store",
|
||||||
|
"Thumbs.db",
|
||||||
|
"desktop.ini",
|
||||||
|
"*.lnk",
|
||||||
|
# Logs & temp files
|
||||||
|
"*.log",
|
||||||
|
"*.tmp",
|
||||||
|
"*.temp",
|
||||||
|
"*.bak",
|
||||||
|
"*.cache",
|
||||||
|
".cache",
|
||||||
|
"logs",
|
||||||
|
# Coverage & test artifacts
|
||||||
|
".coverage",
|
||||||
|
"coverage",
|
||||||
|
".nyc_output",
|
||||||
|
"htmlcov",
|
||||||
|
".pytest_cache",
|
||||||
|
".mypy_cache",
|
||||||
|
".ruff_cache",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _should_ignore(name: str) -> bool:
|
||||||
|
"""Check if a file/directory name matches any ignore pattern."""
|
||||||
|
for pattern in IGNORE_PATTERNS:
|
||||||
|
if fnmatch.fnmatch(name, pattern):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_dir(path: str, max_depth: int = 2) -> list[str]:
|
||||||
|
"""
|
||||||
|
List files and directories up to max_depth levels deep.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The root directory path to list.
|
||||||
|
max_depth: Maximum depth to traverse (default: 2).
|
||||||
|
1 = only direct children, 2 = children + grandchildren, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of absolute paths for files and directories,
|
||||||
|
excluding items matching IGNORE_PATTERNS.
|
||||||
|
"""
|
||||||
|
result: list[str] = []
|
||||||
|
root_path = Path(path).resolve()
|
||||||
|
|
||||||
|
if not root_path.is_dir():
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _traverse(current_path: Path, current_depth: int) -> None:
|
||||||
|
"""Recursively traverse directories up to max_depth."""
|
||||||
|
if current_depth > max_depth:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in current_path.iterdir():
|
||||||
|
if _should_ignore(item.name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
post_fix = "/" if item.is_dir() else ""
|
||||||
|
result.append(str(item.resolve()) + post_fix)
|
||||||
|
|
||||||
|
# Recurse into subdirectories if not at max depth
|
||||||
|
if item.is_dir() and current_depth < max_depth:
|
||||||
|
_traverse(item, current_depth + 1)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_traverse(root_path, 1)
|
||||||
|
|
||||||
|
return sorted(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\n".join(list_dir("/Users/Henry/Desktop", max_depth=2)))
|
||||||
46
backend/src/sandbox/local/local_sandbox.py
Normal file
46
backend/src/sandbox/local/local_sandbox.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from src.sandbox.local.list_dir import list_dir
|
||||||
|
from src.sandbox.sandbox import Sandbox
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSandbox(Sandbox):
|
||||||
|
def __init__(self, id: str):
|
||||||
|
super().__init__(id)
|
||||||
|
|
||||||
|
def execute_command(self, command: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
executable="/bin/zsh",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
output = result.stdout
|
||||||
|
if result.stderr:
|
||||||
|
output += f"\nStd Error:\n{result.stderr}" if output else result.stderr
|
||||||
|
if result.returncode != 0:
|
||||||
|
output += f"\nExit Code: {result.returncode}"
|
||||||
|
return output if output else "(no output)"
|
||||||
|
|
||||||
|
def list_dir(self, path: str, max_depth=2) -> list[str]:
|
||||||
|
return list_dir(path, max_depth)
|
||||||
|
|
||||||
|
def read_file(self, path: str) -> str:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||||
|
dir_path = os.path.dirname(path)
|
||||||
|
if dir_path:
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
mode = "a" if append else "w"
|
||||||
|
with open(path, mode) as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sandbox = LocalSandbox("test")
|
||||||
|
print(sandbox.list_dir("/Users/Henry/mnt"))
|
||||||
21
backend/src/sandbox/local/local_sandbox_provider.py
Normal file
21
backend/src/sandbox/local/local_sandbox_provider.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from src.sandbox.local.local_sandbox import LocalSandbox
|
||||||
|
from src.sandbox.sandbox import Sandbox
|
||||||
|
from src.sandbox.sandbox_provider import SandboxProvider
|
||||||
|
|
||||||
|
_singleton: LocalSandbox | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSandboxProvider(SandboxProvider):
|
||||||
|
def acquire(self) -> Sandbox:
|
||||||
|
global _singleton
|
||||||
|
if _singleton is None:
|
||||||
|
_singleton = LocalSandbox("local")
|
||||||
|
return _singleton.id
|
||||||
|
|
||||||
|
def get(self, sandbox_id: str) -> None:
|
||||||
|
if _singleton is None:
|
||||||
|
self.acquire()
|
||||||
|
return _singleton
|
||||||
|
|
||||||
|
def release(self, sandbox_id: str) -> None:
|
||||||
|
pass
|
||||||
62
backend/src/sandbox/sandbox.py
Normal file
62
backend/src/sandbox/sandbox.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class Sandbox(ABC):
|
||||||
|
"""Abstract base class for sandbox environments"""
|
||||||
|
|
||||||
|
_id: str
|
||||||
|
|
||||||
|
def __init__(self, id: str):
|
||||||
|
self._id = id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def execute_command(self, command: str) -> str:
|
||||||
|
"""Execute bash command in sandbox.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The command to execute.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The standard or error output of the command.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def read_file(self, path: str) -> str:
|
||||||
|
"""Read tge content of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The absolute path of the file to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The content of the file.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_dir(self, path: str, max_depth=2) -> list[str]:
|
||||||
|
"""List the contents of a directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The absolute path of the directory to list.
|
||||||
|
max_depth: The maximum depth to traverse. Default is 2.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The contents of the directory.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||||
|
"""Write content to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The absolute path of the file to write to.
|
||||||
|
content: The text content to write to the file.
|
||||||
|
append: Whether to append the content to the file. If False, the file will be created or overwritten.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
53
backend/src/sandbox/sandbox_provider.py
Normal file
53
backend/src/sandbox/sandbox_provider.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from src.config import get_app_config
|
||||||
|
from src.reflection import resolve_class
|
||||||
|
from src.sandbox.sandbox import Sandbox
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxProvider(ABC):
|
||||||
|
"""Abstract base class for sandbox providers"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def acquire(self) -> str:
|
||||||
|
"""Acquire a sandbox environment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the acquired sandbox environment.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, sandbox_id: str) -> Sandbox:
|
||||||
|
"""Get a sandbox environment by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox_id: The ID of the sandbox environment to retain.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def release(self, sandbox_id: str) -> None:
|
||||||
|
"""Release a sandbox environment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sandbox_id: The ID of the sandbox environment to destroy.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_default_sandbox_provider: SandboxProvider | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sandbox_provider() -> SandboxProvider:
|
||||||
|
"""Get the sandbox provider.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A sandbox provider.
|
||||||
|
"""
|
||||||
|
global _default_sandbox_provider
|
||||||
|
if _default_sandbox_provider is None:
|
||||||
|
config = get_app_config()
|
||||||
|
cls = resolve_class(config.sandbox.use, SandboxProvider)
|
||||||
|
_default_sandbox_provider = cls()
|
||||||
|
return _default_sandbox_provider
|
||||||
123
backend/src/sandbox/tools.py
Normal file
123
backend/src/sandbox/tools.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
from src.sandbox.sandbox_provider import get_sandbox_provider
|
||||||
|
|
||||||
|
|
||||||
|
@tool("bash", parse_docstring=True)
|
||||||
|
def bash_tool(description: str, command: str) -> str:
|
||||||
|
"""Execute a bash command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Explain why you are running this command in short words.
|
||||||
|
command: The bash command to execute. Always use absolute paths for files and directories.
|
||||||
|
"""
|
||||||
|
# TODO: get sandbox ID from LangGraph's context
|
||||||
|
sandbox_id = "local"
|
||||||
|
sandbox = get_sandbox_provider().get(sandbox_id)
|
||||||
|
try:
|
||||||
|
return sandbox.execute_command(command)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool("ls", parse_docstring=True)
|
||||||
|
def ls_tool(description: str, path: str) -> str:
|
||||||
|
"""List the contents of a directory up to 2 levels deep in tree format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Explain why you are listing this directory in short words.
|
||||||
|
path: The **absolute** path to the directory to list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: get sandbox ID from LangGraph's context
|
||||||
|
sandbox = get_sandbox_provider().get("local")
|
||||||
|
children = sandbox.list_dir(path)
|
||||||
|
if not children:
|
||||||
|
return "(empty)"
|
||||||
|
return "\n".join(children)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool("read_file", parse_docstring=True)
|
||||||
|
def read_file_tool(
|
||||||
|
description: str,
|
||||||
|
path: str,
|
||||||
|
view_range: tuple[int, int] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Read the contents of a text file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Explain why you are viewing this file in short words.
|
||||||
|
path: The **absolute** path to the file to read.
|
||||||
|
view_range: The range of lines to view. The range is inclusive and starts at 1. For example, (1, 10) will view the first 10 lines of the file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: get sandbox ID from LangGraph's context
|
||||||
|
sandbox = get_sandbox_provider().get("local")
|
||||||
|
content = sandbox.read_file(path)
|
||||||
|
if not content:
|
||||||
|
return "(empty)"
|
||||||
|
if view_range:
|
||||||
|
start, end = view_range
|
||||||
|
content = "\n".join(content.splitlines()[start - 1 : end])
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool("write_file", parse_docstring=True)
|
||||||
|
def write_file_tool(
|
||||||
|
description: str,
|
||||||
|
path: str,
|
||||||
|
content: str,
|
||||||
|
append: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Write text content to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Explain why you are writing to this file in short words.
|
||||||
|
path: The **absolute** path to the file to write to.
|
||||||
|
content: The content to write to the file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: get sandbox ID from LangGraph's context
|
||||||
|
sandbox = get_sandbox_provider().get("local")
|
||||||
|
sandbox.write_file(path, content, append)
|
||||||
|
return "OK"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool("str_replace", parse_docstring=True)
|
||||||
|
def str_replace_tool(
|
||||||
|
description: str,
|
||||||
|
path: str,
|
||||||
|
old_str: str,
|
||||||
|
new_str: str,
|
||||||
|
replace_all: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Replace a substring in a file with another substring.
|
||||||
|
If `replace_all` is False (default), the substring to replace must appear **exactly once** in the file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Explain why you are replacing the substring in short words.
|
||||||
|
path: The **absolute** path to the file to replace the substring in.
|
||||||
|
old_str: The substring to replace.
|
||||||
|
new_str: The new substring.
|
||||||
|
replace_all: Whether to replace all occurrences of the substring. If False, only the first occurrence will be replaced. Default is False.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# TODO: get sandbox ID from LangGraph's context
|
||||||
|
sandbox = get_sandbox_provider().get("local")
|
||||||
|
content = sandbox.read_file(path)
|
||||||
|
if not content:
|
||||||
|
return "OK"
|
||||||
|
if replace_all:
|
||||||
|
content = content.replace(old_str, new_str)
|
||||||
|
else:
|
||||||
|
content = content.replace(old_str, new_str, 1)
|
||||||
|
sandbox.write_file(path, content)
|
||||||
|
return "OK"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
Reference in New Issue
Block a user