mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 20:03:12 +00:00
feat: use sandbox inside of code executor
This commit is contained in:
@@ -37,7 +37,7 @@ def _cap_bytes(data: bytes, limit: int) -> str:
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
class BashExecutor:
|
||||
class LocalBashExecutor:
|
||||
"""Async subprocess executor with resource isolation (Unix) and output capping.
|
||||
|
||||
On Unix: uses os.setsid + setrlimit (CPU, virtual memory, file size, nproc).
|
||||
|
||||
@@ -6,12 +6,12 @@ from typing import TYPE_CHECKING
|
||||
from injector import inject, singleton
|
||||
|
||||
from private_gpt.components.code_execution.registry import CodeExecutionProviderRegistry
|
||||
from private_gpt.settings.settings import Settings # noqa: TC001
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.code_execution.base import (
|
||||
CodeExecutionProvider,
|
||||
CodeExecutionSession,
|
||||
CodeExecutionSession, # noqa: TC004
|
||||
)
|
||||
from private_gpt.components.code_execution.content_bundle import ContentBundle
|
||||
|
||||
|
||||
@@ -2,259 +2,62 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
from injector import inject
|
||||
|
||||
from private_gpt.components.code_execution.base import (
|
||||
BashExecutionResult,
|
||||
CodeExecutionProvider,
|
||||
CodeExecutionSession,
|
||||
FileOperationResult,
|
||||
)
|
||||
from private_gpt.components.code_execution.bash_executor import BashExecutor
|
||||
from private_gpt.components.code_execution.mount import LocalMount, ReadOnlyMount
|
||||
from private_gpt.components.code_execution.path_translator import PathTranslator
|
||||
from private_gpt.settings.settings import Settings # noqa: TC001
|
||||
from private_gpt.components.code_execution.bash_executor import LocalBashExecutor
|
||||
from private_gpt.components.code_execution.sandbox_session import (
|
||||
SandboxCodeExecutionSession,
|
||||
)
|
||||
from private_gpt.components.sandbox.local_async import BashExecutorSandbox
|
||||
from private_gpt.components.sandbox.mount import (
|
||||
LocalMount,
|
||||
LocalMountSpec,
|
||||
ReadOnlyMount,
|
||||
)
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.code_execution.base import CodeExecutionSession
|
||||
from private_gpt.components.code_execution.content_bundle import ContentBundle
|
||||
from private_gpt.components.code_execution.mount import SessionMount
|
||||
from private_gpt.components.sandbox.mount import SessionMount
|
||||
|
||||
|
||||
def _cache_key(canonical_path: str) -> str:
|
||||
"""Derive a short, filesystem-safe cache directory name from a canonical path."""
|
||||
return hashlib.sha1(canonical_path.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
class LocalCodeExecutionSession(CodeExecutionSession):
|
||||
"""Code execution session backed by the local filesystem.
|
||||
|
||||
All path operations go through PathTranslator:
|
||||
/home/agent/ → {base}/sessions/{id}/workspace/ (writable)
|
||||
/mnt/user-data/ → {base}/sessions/{id}/uploads|outputs/
|
||||
/mnt/skills/... → {base}/content_cache/{hash}/ (read-only)
|
||||
|
||||
Falls back to workspace-relative resolution for bare/relative paths so
|
||||
that existing callers remain compatible.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
translator: PathTranslator,
|
||||
workspace: Path,
|
||||
last_accessed: list[float],
|
||||
executor: BashExecutor,
|
||||
) -> None:
|
||||
self._id = session_id
|
||||
self._translator = translator
|
||||
self._workspace = workspace
|
||||
self._last_accessed = last_accessed # mutable cell shared with provider
|
||||
self._executor = executor
|
||||
|
||||
def _touch(self) -> None:
|
||||
self._last_accessed[0] = time.monotonic()
|
||||
|
||||
def _resolve(self, path: str) -> Path:
|
||||
try:
|
||||
return self._translator.to_real(path)
|
||||
except ValueError:
|
||||
cleaned = path.lstrip("/")
|
||||
candidate = (self._workspace / cleaned).resolve()
|
||||
if (
|
||||
candidate != self._workspace
|
||||
and self._workspace not in candidate.parents
|
||||
):
|
||||
raise ValueError(
|
||||
f"Path '{path}' escapes the session workspace."
|
||||
) from None
|
||||
return candidate
|
||||
|
||||
async def execute_bash(
|
||||
self, command: str, timeout: int | None = None, restart: bool = False
|
||||
) -> BashExecutionResult:
|
||||
self._touch()
|
||||
if restart:
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: shutil.rmtree(self._workspace, ignore_errors=True)
|
||||
)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: self._workspace.mkdir(parents=True, exist_ok=True)
|
||||
)
|
||||
translated = self._translator.rewrite_command(command)
|
||||
result = await self._executor.run(
|
||||
translated, cwd=self._workspace, timeout=timeout
|
||||
)
|
||||
return BashExecutionResult(
|
||||
success=result.success,
|
||||
stdout=self._translator.scrub_output(result.stdout),
|
||||
stderr=self._translator.scrub_output(result.stderr),
|
||||
exit_code=result.exit_code,
|
||||
execution_time_ms=result.execution_time_ms,
|
||||
)
|
||||
|
||||
async def view(
|
||||
self, path: str, view_range: tuple[int, int] | None = None
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
target = self._resolve(path)
|
||||
if not await anyio.to_thread.run_sync(target.exists):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File not found: {path}"
|
||||
)
|
||||
|
||||
if await anyio.to_thread.run_sync(target.is_dir):
|
||||
entries = await anyio.to_thread.run_sync(
|
||||
lambda: [
|
||||
(entry.name, entry.is_dir())
|
||||
for entry in sorted(target.iterdir(), key=lambda e: e.name)
|
||||
]
|
||||
)
|
||||
lines = [
|
||||
f"[dir] {name}" if is_dir else f"[file] {name}"
|
||||
for name, is_dir in entries
|
||||
]
|
||||
return FileOperationResult(success=True, output="\n".join(lines))
|
||||
|
||||
text = await anyio.to_thread.run_sync(
|
||||
lambda: target.read_text(encoding="utf-8")
|
||||
)
|
||||
lines = text.splitlines()
|
||||
base_line = 1
|
||||
if view_range is not None:
|
||||
start, end = view_range
|
||||
start_index = max(start, 1) - 1
|
||||
end_index = None if end == -1 else max(end, 0)
|
||||
lines = lines[start_index:end_index]
|
||||
base_line = start_index + 1
|
||||
output = "\n".join(
|
||||
f"{i}: {line}" for i, line in enumerate(lines, start=base_line)
|
||||
)
|
||||
return FileOperationResult(success=True, output=output)
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def str_replace(
|
||||
self, path: str, old_str: str, new_str: str
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
self._translator.assert_writable(path)
|
||||
target = self._resolve(path)
|
||||
if not await anyio.to_thread.run_sync(target.exists):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File not found: {path}"
|
||||
)
|
||||
text = await anyio.to_thread.run_sync(
|
||||
lambda: target.read_text(encoding="utf-8")
|
||||
)
|
||||
occurrences = text.count(old_str)
|
||||
if occurrences == 0:
|
||||
return FileOperationResult(
|
||||
success=False, error="old_str was not found in the file."
|
||||
)
|
||||
if occurrences > 1:
|
||||
return FileOperationResult(
|
||||
success=False, error="old_str appears more than once in the file."
|
||||
)
|
||||
updated = text.replace(old_str, new_str, 1)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: target.write_text(updated, encoding="utf-8")
|
||||
)
|
||||
return FileOperationResult(success=True, output=f"Updated {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def create(self, path: str, file_text: str) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
self._translator.assert_writable(path)
|
||||
target = self._resolve(path)
|
||||
if await anyio.to_thread.run_sync(target.exists):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File already exists: {path}"
|
||||
)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: target.parent.mkdir(parents=True, exist_ok=True)
|
||||
)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: target.write_text(file_text, encoding="utf-8")
|
||||
)
|
||||
return FileOperationResult(success=True, output=f"Created {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def insert(
|
||||
self, path: str, insert_line: int, new_str: str
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
self._translator.assert_writable(path)
|
||||
target = self._resolve(path)
|
||||
if not await anyio.to_thread.run_sync(target.exists):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File not found: {path}"
|
||||
)
|
||||
text = await anyio.to_thread.run_sync(
|
||||
lambda: target.read_text(encoding="utf-8")
|
||||
)
|
||||
lines = text.splitlines()
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return FileOperationResult(
|
||||
success=False,
|
||||
error=f"insert_line {insert_line} is out of range.",
|
||||
)
|
||||
insertion = new_str.splitlines()
|
||||
updated_lines = lines[:insert_line] + insertion + lines[insert_line:]
|
||||
updated = "\n".join(updated_lines)
|
||||
if text.endswith("\n") or new_str.endswith("\n"):
|
||||
updated += "\n"
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: target.write_text(updated, encoding="utf-8")
|
||||
)
|
||||
return FileOperationResult(success=True, output=f"Updated {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def close(self) -> None:
|
||||
pass # Provider owns lifecycle; disk files preserved for kernel-restart recovery
|
||||
|
||||
|
||||
class LocalCodeExecutionProvider(CodeExecutionProvider):
|
||||
"""Provides LocalCodeExecutionSession instances backed by the local filesystem.
|
||||
"""Provides SandboxCodeExecutionSession instances backed by the local filesystem.
|
||||
|
||||
Session directories survive kernel restarts — if a session directory already
|
||||
exists on disk when create_session() is called, the existing files are reused.
|
||||
|
||||
Skills and other ContentBundles are materialised to a shared content cache so
|
||||
that the same version is not re-downloaded for every session.
|
||||
Uses BashExecutorSandbox as the execution backend. Session directories survive
|
||||
kernel restarts — existing files are reused on reconnect.
|
||||
"""
|
||||
|
||||
@inject
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings)
|
||||
ce = settings.code_execution
|
||||
base = Path(
|
||||
ce.workspace_path
|
||||
settings.code_execution.workspace_path
|
||||
or Path(settings.data.local_data_folder) / "code_execution_workspaces"
|
||||
)
|
||||
self._sessions_dir = base / "sessions"
|
||||
self._content_cache = Path(ce.skills_cache_path or base / "content_cache")
|
||||
self._executor = BashExecutor(
|
||||
cpu_limit_seconds=ce.bash_cpu_limit_seconds,
|
||||
memory_limit_mb=ce.bash_memory_limit_mb,
|
||||
fsize_limit_mb=ce.bash_fsize_limit_mb,
|
||||
nproc_limit=ce.bash_nproc_limit,
|
||||
output_cap_bytes=ce.bash_output_cap_bytes,
|
||||
self._content_cache = Path(base / "content_cache")
|
||||
self._executor = LocalBashExecutor(
|
||||
cpu_limit_seconds=settings.bash.cpu_limit_seconds,
|
||||
memory_limit_mb=settings.bash.memory_limit_mb,
|
||||
fsize_limit_mb=settings.bash.fsize_limit_mb,
|
||||
nproc_limit=settings.bash.nproc_limit,
|
||||
output_cap_bytes=settings.bash.output_cap_bytes,
|
||||
)
|
||||
self._ttl = ce.session_ttl_seconds
|
||||
self._active: dict[str, tuple[LocalCodeExecutionSession, list[float]]] = {}
|
||||
self._ttl = settings.code_execution.session_ttl_seconds
|
||||
self._active: dict[str, tuple[SandboxCodeExecutionSession, list[float]]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._reaper_started = False
|
||||
|
||||
@@ -262,34 +65,49 @@ class LocalCodeExecutionProvider(CodeExecutionProvider):
|
||||
self,
|
||||
session_id: str,
|
||||
extra_bundles: list[ContentBundle] | None = None,
|
||||
) -> LocalCodeExecutionSession:
|
||||
) -> SandboxCodeExecutionSession:
|
||||
session_dir = self._sessions_dir / session_id
|
||||
|
||||
mounts: list[SessionMount] = [
|
||||
LocalMount("/home/agent/", session_dir / "workspace", writable=True),
|
||||
LocalMount(
|
||||
"/mnt/user-data/uploads/", session_dir / "uploads", writable=False
|
||||
# Build LocalMountSpec list — typed pydantic models
|
||||
local_specs: list[LocalMountSpec] = [
|
||||
LocalMountSpec(
|
||||
canonical="/home/agent/",
|
||||
real_path=session_dir / "workspace",
|
||||
writable=True,
|
||||
),
|
||||
LocalMount(
|
||||
"/mnt/user-data/outputs/", session_dir / "outputs", writable=True
|
||||
LocalMountSpec(
|
||||
canonical="/mnt/user-data/uploads/",
|
||||
real_path=session_dir / "uploads",
|
||||
writable=False,
|
||||
),
|
||||
LocalMountSpec(
|
||||
canonical="/mnt/user-data/outputs/",
|
||||
real_path=session_dir / "outputs",
|
||||
writable=True,
|
||||
),
|
||||
]
|
||||
|
||||
for bundle in extra_bundles or []:
|
||||
cache_dir = self._content_cache / _cache_key(bundle.canonical_path)
|
||||
mounts.append(ReadOnlyMount(bundle.canonical_path, bundle.files, cache_dir))
|
||||
local_specs.append(
|
||||
LocalMountSpec(
|
||||
canonical=bundle.canonical_path,
|
||||
real_path=cache_dir,
|
||||
writable=bundle.writable,
|
||||
)
|
||||
)
|
||||
|
||||
sandbox = BashExecutorSandbox(local_specs, self._executor)
|
||||
|
||||
# Build SessionMount list — delegates setup to sandbox APIs
|
||||
mounts: list[SessionMount] = [LocalMount(spec) for spec in local_specs[:3]]
|
||||
for bundle, spec in zip(extra_bundles or [], local_specs[3:], strict=True):
|
||||
mounts.append(ReadOnlyMount(spec, bundle.files))
|
||||
|
||||
await asyncio.gather(*[m.prepare(sandbox) for m in mounts])
|
||||
|
||||
real_paths = await asyncio.gather(*[m.prepare() for m in mounts])
|
||||
translator = PathTranslator(
|
||||
[
|
||||
(m.canonical, rp, m.writable)
|
||||
for m, rp in zip(mounts, real_paths, strict=True)
|
||||
]
|
||||
)
|
||||
workspace = real_paths[0]
|
||||
last_accessed: list[float] = [time.monotonic()]
|
||||
session = LocalCodeExecutionSession(
|
||||
session_id, translator, workspace, last_accessed, self._executor
|
||||
session = SandboxCodeExecutionSession(
|
||||
session_id, sandbox, "/home/agent/", last_accessed
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
@@ -299,11 +117,9 @@ class LocalCodeExecutionProvider(CodeExecutionProvider):
|
||||
return session
|
||||
|
||||
def delete_session(self, session: CodeExecutionSession) -> None:
|
||||
if isinstance(session, LocalCodeExecutionSession):
|
||||
if isinstance(session, SandboxCodeExecutionSession):
|
||||
sid = session._id
|
||||
self._active.pop(sid, None)
|
||||
# Disk files preserved — next create_session() with the same session_id
|
||||
# will reuse the existing workspace directory.
|
||||
|
||||
def _ensure_reaper(self) -> None:
|
||||
if not self._reaper_started:
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from private_gpt.components.code_execution.content_bundle import BundledFile
|
||||
|
||||
|
||||
class SessionMount(ABC):
|
||||
"""One mount point for a local session.
|
||||
|
||||
prepare() is awaited concurrently at session creation and returns the real
|
||||
Path on the local filesystem. The PathTranslator is then built from the
|
||||
resolved paths.
|
||||
|
||||
Future local-filesystem backends (s3fs, tmpfs, etc.) implement this interface
|
||||
without touching the session or provider code.
|
||||
"""
|
||||
|
||||
canonical: str
|
||||
writable: bool
|
||||
|
||||
@abstractmethod
|
||||
async def prepare(self) -> Path:
|
||||
"""Initialize the mount and return its real filesystem path."""
|
||||
|
||||
async def teardown(self) -> None: # noqa: B027
|
||||
"""Optional cleanup. Default is a no-op."""
|
||||
|
||||
|
||||
class LocalMount(SessionMount):
|
||||
"""Simple writable-or-read-only local directory."""
|
||||
|
||||
def __init__(self, canonical: str, path: Path, *, writable: bool) -> None:
|
||||
self.canonical = canonical
|
||||
self.writable = writable
|
||||
self._path = path
|
||||
|
||||
async def prepare(self) -> Path:
|
||||
self._path.mkdir(parents=True, exist_ok=True)
|
||||
return self._path
|
||||
|
||||
|
||||
class ReadOnlyMount(SessionMount):
|
||||
"""Generic read-only mount: materialises pre-fetched file content to a local dir.
|
||||
|
||||
Works for any ContentBundle — skills, plugins, or future content types.
|
||||
The cache directory is shared across sessions: if it already exists (from a
|
||||
previous session), files are not re-written.
|
||||
"""
|
||||
|
||||
writable: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
canonical: str,
|
||||
files: list[BundledFile],
|
||||
cache_dir: Path,
|
||||
) -> None:
|
||||
self.canonical = canonical
|
||||
self.writable = False
|
||||
self._files = files
|
||||
self._cache_dir = cache_dir
|
||||
|
||||
async def prepare(self) -> Path:
|
||||
if not self._cache_dir.exists():
|
||||
for file in self._files:
|
||||
target = self._cache_dir / file.path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(file.content)
|
||||
target.chmod(file.permissions)
|
||||
return self._cache_dir
|
||||
149
private_gpt/components/code_execution/sandbox_session.py
Normal file
149
private_gpt/components/code_execution/sandbox_session.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from private_gpt.components.code_execution.base import (
|
||||
BashExecutionResult,
|
||||
CodeExecutionSession,
|
||||
FileOperationResult,
|
||||
)
|
||||
from private_gpt.components.sandbox.base import SandboxExecOptions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.sandbox.base import AsyncSandboxSession
|
||||
|
||||
|
||||
class SandboxCodeExecutionSession(CodeExecutionSession):
|
||||
"""Generic CodeExecutionSession backed by any AsyncSandboxSession.
|
||||
|
||||
The sandbox is responsible for path translation and permission enforcement.
|
||||
This class only adds the CodeExecutionSession protocol on top.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
sandbox: AsyncSandboxSession,
|
||||
workspace_canonical: str,
|
||||
last_accessed: list[float],
|
||||
) -> None:
|
||||
self._id = session_id
|
||||
self._sandbox = sandbox
|
||||
self._workspace = workspace_canonical
|
||||
self._last_accessed = last_accessed
|
||||
|
||||
def _touch(self) -> None:
|
||||
self._last_accessed[0] = time.monotonic()
|
||||
|
||||
async def execute_bash(
|
||||
self, command: str, timeout: int | None = None, restart: bool = False
|
||||
) -> BashExecutionResult:
|
||||
self._touch()
|
||||
if restart:
|
||||
await self._sandbox.exec(
|
||||
f"rm -rf {self._workspace}* {self._workspace}.[!.]*",
|
||||
SandboxExecOptions(cwd="/"),
|
||||
)
|
||||
await self._sandbox.make_dir(self._workspace)
|
||||
result = await self._sandbox.exec(
|
||||
command,
|
||||
SandboxExecOptions(timeout=timeout, cwd=self._workspace),
|
||||
)
|
||||
return BashExecutionResult(
|
||||
success=result.success,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
exit_code=result.exit_code,
|
||||
execution_time_ms=result.execution_time_ms,
|
||||
)
|
||||
|
||||
async def view(
|
||||
self, path: str, view_range: tuple[int, int] | None = None
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
if not await self._sandbox.path_exists(path):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File not found: {path}"
|
||||
)
|
||||
if await self._sandbox.is_dir(path):
|
||||
entries = await self._sandbox.list_dir(path)
|
||||
return FileOperationResult(success=True, output="\n".join(entries))
|
||||
raw = await self._sandbox.read_file(path)
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
base_line = 1
|
||||
if view_range is not None:
|
||||
start, end = view_range
|
||||
start_idx = max(start, 1) - 1
|
||||
end_idx = None if end == -1 else max(end, 0)
|
||||
lines = lines[start_idx:end_idx]
|
||||
base_line = start_idx + 1
|
||||
output = "\n".join(
|
||||
f"{i}: {line}" for i, line in enumerate(lines, start=base_line)
|
||||
)
|
||||
return FileOperationResult(success=True, output=output)
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def str_replace(
|
||||
self, path: str, old_str: str, new_str: str
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
raw = await self._sandbox.read_file(path)
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
occurrences = text.count(old_str)
|
||||
if occurrences == 0:
|
||||
return FileOperationResult(
|
||||
success=False, error="old_str was not found in the file."
|
||||
)
|
||||
if occurrences > 1:
|
||||
return FileOperationResult(
|
||||
success=False,
|
||||
error="old_str appears more than once in the file.",
|
||||
)
|
||||
updated = text.replace(old_str, new_str, 1)
|
||||
await self._sandbox.write_file(path, updated.encode("utf-8"))
|
||||
return FileOperationResult(success=True, output=f"Updated {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def create(self, path: str, file_text: str) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
if await self._sandbox.path_exists(path):
|
||||
return FileOperationResult(
|
||||
success=False, error=f"File already exists: {path}"
|
||||
)
|
||||
await self._sandbox.write_file(path, file_text.encode("utf-8"))
|
||||
return FileOperationResult(success=True, output=f"Created {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def insert(
|
||||
self, path: str, insert_line: int, new_str: str
|
||||
) -> FileOperationResult:
|
||||
self._touch()
|
||||
try:
|
||||
raw = await self._sandbox.read_file(path)
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
if insert_line < 0 or insert_line > len(lines):
|
||||
return FileOperationResult(
|
||||
success=False,
|
||||
error=f"insert_line {insert_line} is out of range.",
|
||||
)
|
||||
insertion = new_str.splitlines()
|
||||
updated_lines = lines[:insert_line] + insertion + lines[insert_line:]
|
||||
updated = "\n".join(updated_lines)
|
||||
if text.endswith("\n") or new_str.endswith("\n"):
|
||||
updated += "\n"
|
||||
await self._sandbox.write_file(path, updated.encode("utf-8"))
|
||||
return FileOperationResult(success=True, output=f"Updated {path}")
|
||||
except Exception as exc:
|
||||
return FileOperationResult(success=False, error=str(exc))
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._sandbox.close()
|
||||
@@ -1,11 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.code_execution.content_bundle import BundledFile
|
||||
from private_gpt.components.sandbox.mount import SandboxMountSpec
|
||||
|
||||
|
||||
class SandboxExecutionResult(BaseModel):
|
||||
"""Result from a sandbox command or code execution."""
|
||||
@@ -68,7 +74,7 @@ class SandboxSession(ABC):
|
||||
def install_package(self, package_name: str) -> SandboxExecutionResult:
|
||||
return self.exec(f"python -m pip install {package_name}")
|
||||
|
||||
def __enter__(self) -> "SandboxSession":
|
||||
def __enter__(self) -> SandboxSession:
|
||||
self.start()
|
||||
return self
|
||||
|
||||
@@ -92,3 +98,80 @@ class SandboxProvider(ABC):
|
||||
|
||||
|
||||
SandboxProviderFactory = type[SandboxProvider] | Callable[[Settings], SandboxProvider]
|
||||
|
||||
|
||||
class AsyncSandboxProvider(ABC):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
@abstractmethod
|
||||
async def create_session(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
timeout: int | None = None,
|
||||
bundle_specs: list[SandboxMountSpec] | None = None,
|
||||
) -> AsyncSandboxSession:
|
||||
"""Create a sandbox session. The session may be lazy until start()."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_session(self, session: AsyncSandboxSession) -> None:
|
||||
"""Delete a sandbox session and release all associated resources."""
|
||||
|
||||
|
||||
AsyncSandboxProviderFactory = (
|
||||
type[AsyncSandboxProvider] | Callable[[Settings], AsyncSandboxProvider]
|
||||
)
|
||||
|
||||
|
||||
class AsyncSandboxSession(ABC):
|
||||
"""Async sandbox session with exec + file operations.
|
||||
|
||||
Permission enforcement: write_file() and chmod() check if the path is
|
||||
in a writable mount.
|
||||
initialize_mount() bypasses this check — for session setup only.
|
||||
make_dir() does not check writable.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def exec(
|
||||
self, command: str, opts: SandboxExecOptions | None = None
|
||||
) -> SandboxExecutionResult:
|
||||
"""Execute a shell command."""
|
||||
|
||||
@abstractmethod
|
||||
async def read_file(self, path: str) -> bytes:
|
||||
"""Read file content."""
|
||||
|
||||
@abstractmethod
|
||||
async def write_file(self, path: str, content: bytes) -> None:
|
||||
"""Write file content. Raises ValueError if path is in a read-only mount."""
|
||||
|
||||
@abstractmethod
|
||||
async def path_exists(self, path: str) -> bool:
|
||||
"""Return True if the path exists."""
|
||||
|
||||
@abstractmethod
|
||||
async def is_dir(self, path: str) -> bool:
|
||||
"""Return True if the path is a directory."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_dir(self, path: str) -> list[str]:
|
||||
"""List directory contents as '[dir] name' / '[file] name' strings."""
|
||||
|
||||
@abstractmethod
|
||||
async def make_dir(
|
||||
self, path: str, *, parents: bool = True, exist_ok: bool = True
|
||||
) -> None:
|
||||
"""Create directory. Does not check writable."""
|
||||
|
||||
@abstractmethod
|
||||
async def chmod(self, path: str, mode: int) -> None:
|
||||
"""Set file permissions. Raises ValueError if path is in a read-only mount."""
|
||||
|
||||
@abstractmethod
|
||||
async def initialize_mount(self, canonical: str, files: list[BundledFile]) -> None:
|
||||
"""Write mount files during session setup. Bypasses writable check."""
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Release resources."""
|
||||
|
||||
130
private_gpt/components/sandbox/local_async.py
Normal file
130
private_gpt/components/sandbox/local_async.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
import anyio.to_thread
|
||||
|
||||
from private_gpt.components.code_execution.path_translator import (
|
||||
PathTranslator,
|
||||
)
|
||||
from private_gpt.components.sandbox.base import (
|
||||
AsyncSandboxSession,
|
||||
SandboxExecutionResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from private_gpt.components.code_execution.bash_executor import (
|
||||
LocalBashExecutor,
|
||||
)
|
||||
from private_gpt.components.code_execution.content_bundle import BundledFile
|
||||
from private_gpt.components.sandbox.base import (
|
||||
SandboxExecOptions,
|
||||
)
|
||||
from private_gpt.components.sandbox.mount import LocalMountSpec
|
||||
|
||||
|
||||
class BashExecutorSandbox(AsyncSandboxSession):
|
||||
"""Local async sandbox: BashExecutor for exec, pathlib for file ops.
|
||||
|
||||
Translates canonical paths → real local paths via PathTranslator.
|
||||
Enforces read-only constraints on write_file() and chmod().
|
||||
initialize_mount() bypasses the check for session setup.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, mounts: list[LocalMountSpec], executor: LocalBashExecutor
|
||||
) -> None:
|
||||
self._translator = PathTranslator(
|
||||
[(m.canonical, m.real_path, m.writable) for m in mounts]
|
||||
)
|
||||
self._executor = executor
|
||||
self._readonly = [m.canonical for m in mounts if not m.writable]
|
||||
|
||||
def _assert_writable(self, path: str) -> None:
|
||||
for prefix in self._readonly:
|
||||
if path.startswith(prefix):
|
||||
raise ValueError(f"Path '{path}' is in a read-only mount ('{prefix}').")
|
||||
|
||||
async def exec(
|
||||
self, command: str, opts: SandboxExecOptions | None = None
|
||||
) -> SandboxExecutionResult:
|
||||
cwd = self._translator.to_real((opts.cwd if opts else None) or "/home/agent/")
|
||||
cmd = self._translator.rewrite_command(command)
|
||||
result = await self._executor.run(
|
||||
cmd, cwd=cwd, timeout=opts.timeout if opts else None
|
||||
)
|
||||
return SandboxExecutionResult(
|
||||
success=result.success,
|
||||
stdout=self._translator.scrub_output(result.stdout),
|
||||
stderr=self._translator.scrub_output(result.stderr),
|
||||
exit_code=result.exit_code,
|
||||
execution_time_ms=result.execution_time_ms,
|
||||
)
|
||||
|
||||
async def read_file(self, path: str) -> bytes:
|
||||
real = self._translator.to_real(path)
|
||||
return await anyio.to_thread.run_sync(real.read_bytes)
|
||||
|
||||
async def write_file(self, path: str, content: bytes) -> None:
|
||||
self._assert_writable(path)
|
||||
real = self._translator.to_real(path)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: real.parent.mkdir(parents=True, exist_ok=True)
|
||||
)
|
||||
await anyio.to_thread.run_sync(lambda: real.write_bytes(content))
|
||||
|
||||
async def path_exists(self, path: str) -> bool:
|
||||
real = self._translator.to_real(path)
|
||||
return await anyio.to_thread.run_sync(real.exists)
|
||||
|
||||
async def is_dir(self, path: str) -> bool:
|
||||
real = self._translator.to_real(path)
|
||||
return await anyio.to_thread.run_sync(real.is_dir)
|
||||
|
||||
async def list_dir(self, path: str) -> list[str]:
|
||||
real = self._translator.to_real(path)
|
||||
entries: list[tuple[str, bool]] = await anyio.to_thread.run_sync(
|
||||
lambda: [
|
||||
(e.name, e.is_dir())
|
||||
for e in sorted(real.iterdir(), key=lambda e: e.name)
|
||||
]
|
||||
)
|
||||
return [f"[dir] {name}" if is_d else f"[file] {name}" for name, is_d in entries]
|
||||
|
||||
async def make_dir(
|
||||
self, path: str, *, parents: bool = True, exist_ok: bool = True
|
||||
) -> None:
|
||||
real = self._translator.to_real(path)
|
||||
await anyio.to_thread.run_sync(
|
||||
lambda: real.mkdir(parents=parents, exist_ok=exist_ok)
|
||||
)
|
||||
|
||||
async def chmod(self, path: str, mode: int) -> None:
|
||||
self._assert_writable(path)
|
||||
real = self._translator.to_real(path)
|
||||
await anyio.to_thread.run_sync(lambda: real.chmod(mode))
|
||||
|
||||
async def initialize_mount(self, canonical: str, files: list[BundledFile]) -> None:
|
||||
for f in files:
|
||||
real = self._translator.to_real(canonical + f.path)
|
||||
content = f.content
|
||||
permissions = f.permissions
|
||||
|
||||
def _mkdir(r: Path = real) -> None:
|
||||
r.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _write(r: Path = real, c: bytes = content) -> None:
|
||||
r.write_bytes(c)
|
||||
|
||||
def _chmod(r: Path = real, p: int = permissions) -> None:
|
||||
r.chmod(p)
|
||||
|
||||
await anyio.to_thread.run_sync(_mkdir)
|
||||
await anyio.to_thread.run_sync(_write)
|
||||
await anyio.to_thread.run_sync(_chmod)
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
74
private_gpt/components/sandbox/mount.py
Normal file
74
private_gpt/components/sandbox/mount.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.code_execution.content_bundle import BundledFile
|
||||
from private_gpt.components.sandbox.base import AsyncSandboxSession
|
||||
|
||||
|
||||
class SandboxMountSpec(BaseModel):
|
||||
"""Canonical mount point visible to the agent — backend-agnostic."""
|
||||
|
||||
canonical: str # e.g. "/home/agent/" — must end with "/"
|
||||
writable: bool = True
|
||||
|
||||
|
||||
class LocalMountSpec(SandboxMountSpec):
|
||||
"""Local-filesystem mount: adds the real path used by BashExecutorSandbox."""
|
||||
|
||||
real_path: Path
|
||||
|
||||
|
||||
class SessionMount(ABC):
|
||||
"""Describes how to set up one mount point using sandbox APIs."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def spec(self) -> SandboxMountSpec:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def prepare(self, sandbox: AsyncSandboxSession) -> None:
|
||||
"""Initialize this mount via sandbox APIs."""
|
||||
|
||||
async def teardown(self, sandbox: AsyncSandboxSession) -> None: # noqa: B027
|
||||
"""Optional cleanup. Default is a no-op."""
|
||||
|
||||
|
||||
class LocalMount(SessionMount):
|
||||
"""Creates a directory via sandbox.make_dir()."""
|
||||
|
||||
def __init__(self, spec: SandboxMountSpec) -> None:
|
||||
self._spec = spec
|
||||
|
||||
@property
|
||||
def spec(self) -> SandboxMountSpec:
|
||||
return self._spec
|
||||
|
||||
async def prepare(self, sandbox: AsyncSandboxSession) -> None:
|
||||
await sandbox.make_dir(self._spec.canonical)
|
||||
|
||||
|
||||
class ReadOnlyMount(SessionMount):
|
||||
"""Materialises BundledFiles via sandbox.initialize_mount(). Idempotent.
|
||||
|
||||
For local backends path_exists() checks the real cache directory; if it
|
||||
already exists the files are not re-written.
|
||||
"""
|
||||
|
||||
def __init__(self, spec: SandboxMountSpec, files: list[BundledFile]) -> None:
|
||||
self._spec = spec
|
||||
self._files = files
|
||||
|
||||
@property
|
||||
def spec(self) -> SandboxMountSpec:
|
||||
return self._spec
|
||||
|
||||
async def prepare(self, sandbox: AsyncSandboxSession) -> None:
|
||||
if not await sandbox.path_exists(self._spec.canonical):
|
||||
await sandbox.initialize_mount(self._spec.canonical, self._files)
|
||||
@@ -1,20 +1,29 @@
|
||||
from private_gpt.components.sandbox.base import SandboxProvider, SandboxProviderFactory
|
||||
from private_gpt.components.sandbox.base import (
|
||||
AsyncSandboxProvider,
|
||||
AsyncSandboxProviderFactory,
|
||||
SandboxProvider,
|
||||
SandboxProviderFactory,
|
||||
)
|
||||
from private_gpt.components.sandbox.local import LocalSandboxProvider
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
_PROVIDERS: dict[str, SandboxProviderFactory] = {"local": LocalSandboxProvider}
|
||||
_PROVIDERS: dict[str, SandboxProviderFactory | AsyncSandboxProviderFactory] = {
|
||||
"local": LocalSandboxProvider
|
||||
}
|
||||
|
||||
|
||||
def register_sandbox(name: str, provider: SandboxProviderFactory) -> None:
|
||||
def register_sandbox(
|
||||
name: str, provider: SandboxProviderFactory | AsyncSandboxProviderFactory
|
||||
) -> None:
|
||||
_PROVIDERS[name] = provider
|
||||
|
||||
|
||||
class SandboxProviderRegistry:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._providers: dict[str, SandboxProvider] = {}
|
||||
self._providers: dict[str, SandboxProvider | AsyncSandboxProvider] = {}
|
||||
|
||||
def get_provider(self, name: str) -> SandboxProvider:
|
||||
def get_provider(self, name: str) -> SandboxProvider | AsyncSandboxProvider:
|
||||
provider = self._providers.get(name)
|
||||
if provider is not None:
|
||||
return provider
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from injector import inject, singleton
|
||||
|
||||
from private_gpt.components.sandbox.base import SandboxSession
|
||||
from private_gpt.components.sandbox.base import (
|
||||
AsyncSandboxProvider,
|
||||
AsyncSandboxSession,
|
||||
SandboxProvider,
|
||||
SandboxSession,
|
||||
)
|
||||
from private_gpt.components.sandbox.mount import SandboxMountSpec
|
||||
from private_gpt.components.sandbox.registry import SandboxProviderRegistry
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
@@ -18,5 +24,25 @@ class SandboxComponent:
|
||||
provider_name = self._settings.sandbox.provider
|
||||
if provider_name is None:
|
||||
return None
|
||||
|
||||
provider = self._registry.get_provider(provider_name)
|
||||
assert isinstance(provider, SandboxProvider)
|
||||
|
||||
return provider.create_session(user_id=user_id, timeout=timeout)
|
||||
|
||||
async def acreate_session(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
timeout: int | None = None,
|
||||
bundle_specs: list[SandboxMountSpec] | None = None,
|
||||
) -> AsyncSandboxSession | None:
|
||||
provider_name = self._settings.sandbox.provider
|
||||
if provider_name is None:
|
||||
return None
|
||||
|
||||
provider = self._registry.get_provider(provider_name)
|
||||
assert isinstance(provider, AsyncSandboxProvider)
|
||||
|
||||
return await provider.create_session(
|
||||
user_id=user_id, timeout=timeout, bundle_specs=bundle_specs
|
||||
)
|
||||
|
||||
@@ -6,15 +6,15 @@ from injector import inject, singleton
|
||||
|
||||
from private_gpt.components.chat.models.chat_config_models import ToolSpec
|
||||
from private_gpt.components.code_execution.code_execution_component import (
|
||||
CodeExecutionComponent, # noqa: TC001
|
||||
CodeExecutionComponent,
|
||||
)
|
||||
from private_gpt.components.skills.services.skill_loader import (
|
||||
SkillLoader, # noqa: TC001
|
||||
SkillLoader,
|
||||
)
|
||||
from private_gpt.components.tools.tool_names import BASH_TOOL_NAME
|
||||
from private_gpt.components.tools.tool_placeholders import BASH_TOOL_FN
|
||||
from private_gpt.events.models import TextBlock
|
||||
from private_gpt.settings.settings import Settings # noqa: TC001
|
||||
from private_gpt.settings.settings import Settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from private_gpt.components.skills.models.skill_entities import SkillFilter
|
||||
|
||||
@@ -17,6 +17,9 @@ from injector import Injector
|
||||
from llama_index.core.embeddings import MockEmbedding
|
||||
from llama_index.core.settings import Settings as LlamaIndexSettings
|
||||
|
||||
from private_gpt.components.code_execution.code_execution_component import (
|
||||
CodeExecutionComponent,
|
||||
)
|
||||
from private_gpt.components.embedding.embedding_component import EmbeddingComponent
|
||||
from private_gpt.components.llm.llm_component import LLMComponent
|
||||
from private_gpt.components.node_store.node_store_component import NodeStoreComponent
|
||||
@@ -82,6 +85,7 @@ def eager_loading(injector: Injector) -> None:
|
||||
logger.debug("Initializing auxiliar services")
|
||||
injector.get(PromptBuilderService)
|
||||
injector.get(ToolService)
|
||||
injector.get(CodeExecutionComponent)
|
||||
|
||||
|
||||
def apply_migrations(injector: Injector) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from injector import inject, singleton
|
||||
@@ -210,7 +211,9 @@ class ChatRequestMapper:
|
||||
tool_context=body.tool_context or [],
|
||||
context=ResolvedContextConfig(
|
||||
correlation_id=body.correlation_id,
|
||||
user_id=body.metadata.user_id if body.metadata else None,
|
||||
user_id=body.metadata.user_id
|
||||
if body.metadata and body.metadata.user_id
|
||||
else str(uuid.uuid4()),
|
||||
maximum_loaded_skills=(
|
||||
body.maximum_loaded_skills
|
||||
if body.maximum_loaded_skills is not None
|
||||
|
||||
@@ -1402,10 +1402,6 @@ class SandboxSettings(BaseModel):
|
||||
description="Sandbox provider registered by the application layer. "
|
||||
"Defaults to null (disabled); set explicitly to enable sandbox usage.",
|
||||
)
|
||||
base_url: str | None = Field(
|
||||
default=None,
|
||||
description="Base URL for remote sandbox providers.",
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=60,
|
||||
description="Default sandbox operation timeout in seconds.",
|
||||
@@ -1419,20 +1415,26 @@ class SandboxSettings(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class OpenSandboxSettings(BaseModel):
|
||||
base_url: str = Field(description="Base URL of the OpenSandbox server.")
|
||||
api_key: str | None = Field(default=None, description="API key for OpenSandbox.")
|
||||
image: str = Field(
|
||||
default="python:3.11",
|
||||
description="Container image used for sandbox sessions.",
|
||||
class BashSettings(BaseModel):
|
||||
cpu_limit_seconds: int = Field(
|
||||
default=30,
|
||||
description="RLIMIT_CPU applied to each isolated bash subprocess.",
|
||||
)
|
||||
session_ttl_seconds: int = Field(
|
||||
default=3600,
|
||||
description="How long (seconds) a sandbox container lives before expiry.",
|
||||
memory_limit_mb: int = Field(
|
||||
default=512,
|
||||
description="RLIMIT_AS in MB applied to each isolated bash subprocess.",
|
||||
)
|
||||
resource_limits: dict[str, str] = Field(
|
||||
default_factory=lambda: {"cpu": "500m", "memory": "512Mi"},
|
||||
description="Resource limits applied to each sandbox container.",
|
||||
fsize_limit_mb: int = Field(
|
||||
default=50,
|
||||
description="RLIMIT_FSIZE in MB applied to each isolated bash subprocess.",
|
||||
)
|
||||
nproc_limit: int = Field(
|
||||
default=50,
|
||||
description="RLIMIT_NPROC applied to each isolated bash subprocess.",
|
||||
)
|
||||
output_cap_bytes: int = Field(
|
||||
default=10 * 1024 * 1024,
|
||||
description="Hard cap on raw subprocess output bytes before LLM truncation.",
|
||||
)
|
||||
|
||||
|
||||
@@ -1460,35 +1462,6 @@ class CodeExecutionSettings(BaseModel):
|
||||
description="Idle TTL in seconds before a local session kernel is destroyed. "
|
||||
"Workspace files are preserved for restart.",
|
||||
)
|
||||
skills_cache_path: str | None = Field(
|
||||
default=None,
|
||||
description="Directory for cached skill/plugin file trees. "
|
||||
"Defaults to {workspace_path}/content_cache.",
|
||||
)
|
||||
bash_cpu_limit_seconds: int = Field(
|
||||
default=30,
|
||||
description="RLIMIT_CPU applied to each isolated bash subprocess.",
|
||||
)
|
||||
bash_memory_limit_mb: int = Field(
|
||||
default=512,
|
||||
description="RLIMIT_AS in MB applied to each isolated bash subprocess.",
|
||||
)
|
||||
bash_fsize_limit_mb: int = Field(
|
||||
default=50,
|
||||
description="RLIMIT_FSIZE in MB applied to each isolated bash subprocess.",
|
||||
)
|
||||
bash_nproc_limit: int = Field(
|
||||
default=50,
|
||||
description="RLIMIT_NPROC applied to each isolated bash subprocess.",
|
||||
)
|
||||
bash_output_cap_bytes: int = Field(
|
||||
default=10 * 1024 * 1024,
|
||||
description="Hard cap on raw subprocess output bytes before LLM truncation.",
|
||||
)
|
||||
opensandbox: OpenSandboxSettings | None = Field(
|
||||
default=None,
|
||||
description="OpenSandbox provider settings. Required when provider='opensandbox'.",
|
||||
)
|
||||
|
||||
@field_validator("provider", mode="before")
|
||||
@classmethod
|
||||
@@ -1600,19 +1573,15 @@ class Settings(BaseModel):
|
||||
phoenix: ArizePhoenixSettings
|
||||
opik: OpikSettings
|
||||
sandbox: SandboxSettings
|
||||
code_execution: CodeExecutionSettings = Field(default_factory=CodeExecutionSettings)
|
||||
bash: BashSettings
|
||||
code_execution: CodeExecutionSettings
|
||||
web_fetch: WebFetchSettings
|
||||
web_search: WebSearchSettings
|
||||
database_query: DatabaseQuerySettings
|
||||
brave: BraveSearchSettings
|
||||
skills: SkillSettings = Field(default_factory=SkillSettings)
|
||||
transformation: TransformationSettings = Field(
|
||||
default_factory=TransformationSettings
|
||||
)
|
||||
semaphore: SemaphoreSettings = Field(
|
||||
default_factory=SemaphoreSettings,
|
||||
description="Settings for the semaphore manager",
|
||||
)
|
||||
skills: SkillSettings
|
||||
transformation: TransformationSettings
|
||||
semaphore: SemaphoreSettings
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@@ -409,6 +409,7 @@ ban-relative-imports = "all"
|
||||
[tool.ruff.lint.flake8-type-checking]
|
||||
strict = true
|
||||
runtime-evaluated-base-classes = ["pydantic.BaseModel"]
|
||||
runtime-evaluated-decorators = ["injector.inject"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["D"]
|
||||
|
||||
@@ -220,16 +220,21 @@ opik:
|
||||
|
||||
sandbox:
|
||||
provider: ${PGPT_SANDBOX_PROVIDER:local}
|
||||
base_url: ${PGPT_SANDBOX_URL:}
|
||||
timeout: ${PGPT_SANDBOX_TIMEOUT:60}
|
||||
|
||||
|
||||
code_execution:
|
||||
provider: ${PGPT_CODE_EXECUTION_PROVIDER:local}
|
||||
workspace_path: ${PGPT_CODE_EXECUTION_WORKSPACE_PATH:}
|
||||
timeout: ${PGPT_CODE_EXECUTION_TIMEOUT:60}
|
||||
max_output_bytes: ${PGPT_CODE_EXECUTION_MAX_OUTPUT_BYTES:1048576}
|
||||
|
||||
bash:
|
||||
cpu_limit_seconds: ${PGPT_BASH_CPU_LIMIT_SECONDS:30}
|
||||
memory_limit_mb: ${PGPT_BASH_MEMORY_LIMIT_MB:512}
|
||||
fsize_limit_mb: ${PGPT_BASH_FSIZE_LIMIT_MB:10}
|
||||
nproc_limit: ${PGPT_BASH_NPROC_LIMIT:50}
|
||||
output_cap_bytes: ${PGPT_BASH_OUTPUT_CAP_BYTES:1048576}
|
||||
|
||||
web_fetch:
|
||||
enabled: ${PGPT_WEBFETCH_ENABLED:false}
|
||||
timeout_seconds: ${PGPT_WEBFETCH_TIMEOUT_SECONDS:15}
|
||||
|
||||
Reference in New Issue
Block a user