Files
Javier Martinez 21d42fd97a feat: code execution v4 (#2295)
* feat: add bundle to remove

* fix: spaces

* feat: add xml render as skill spec

* feat: add skill volume root to cache

* fix: deduplicate values

* fix: change the mount path to skill_id

* fix: use different paths

* feat: add principal

(cherry picked from commit 5db64fe721d5706440ce9f342b1388ffe742bc16)

# Conflicts:
#	private_gpt/components/code_execution/base.py
#	private_gpt/components/code_execution/code_execution_component.py
#	private_gpt/components/code_execution/local.py
#	private_gpt/components/environment/manager.py
#	private_gpt/components/tools/builders/bash_tool_builder.py
#	private_gpt/components/tools/builders/text_editor_tool_builder.py
#	private_gpt/components/tools/processors/bash_processor.py

* fix: mypy

...

* fix: config

* fix: sandbox config

* feat: add forward cookies

* fix: loop

* feat: add present server

* feat: add feature flag for tools

* refactor: move principal to another better place

* feat: add api key principal

* docs: fix docs

* docs: add present server

* fix: principal

* fix: mypy

* fix: avoid to block the loop

* fix: blocks in expansion

* fix: remove maximum concurrent users

...

* fix: multiplexer

* fix: readers

* fix: more fixes

...

* fix: impl

* feat: tool scheduler

* feat: add adaptative

* feat: add chat worker

* fix: config

* fix: max

* feat: add chat/tools workers

* fix: mypy

* feat: add generic scheduler

* fix: get result

* feat: do serializable the tool executor

* fix: tools

* fix: config

* fix: config

* fix: args

* fix: config

* fix: serializer

* Revert "fix: blocks in expansion"

This reverts commit a2110f94a8.

* fix: unify all logic

* feat: add ingestion scheduler

* fix: settings

* fix: config

* feat: add arq worker to chat

* fix: arq worker

* fix: add nest

* fix: mypy

* fix: await

* fix: script stress

* fix: tokenizer

* fix: chat scheduler

* fix: mypy

* fix: add async tokenizer

* fix: improve condense

* fix: tool scheduler

* feat: add initial real async chat worker

* fix: mypy

* fix: do resumable local executor

...

...

...

fix: revert usleess changes

fix: remove parent chat job

fix: refactor

fix: loop

ref: rename models

fix: chat engine

fix: mypy

...

...

...

fix: fix deps

* fix: tests

* fix: tests

* ...

* fix: stream

* fix: config

* fix: scheduler

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Handle PGPT_WORKER_MODE=celery in health check worker status

* fix: cancel

* fix: arch

* fix: test ingestion

* fix: deserialization of chat messages

* fix: broken results

* fix: mypy

* fix: test

* fix: config

* fix: remove arq tool worker

* fix: output cls

* fix: preserve early resumable tool callbacks

* fix: preserve async tool result order

* refactor: address worker PR review comments

* fix: mypy

* test: colocate ARQ chat enqueue coverage

* fix: remove redis from tests

* fix: mypy

* fix: tests

* test: isolate chat mocks and cancellation timing

* fix: tests

* fix: tests

* fix: test

(cherry picked from commit f8ee460af2)

* fix: worker config

* test: remove flaky chat cancellation assertion

(cherry picked from commit 1115ff2349)

# Conflicts:
#	tests/server/chat/test_chat_routes.py

* fix: emit chat pings from stream listeners

* fix: don't duplciate the output

* fix: rss memory

...

* fix: pass the args

* fix: threads

* fix: websearch

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 13:05:23 +02:00

216 lines
7.0 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
from private_gpt.settings.settings import Settings
if TYPE_CHECKING:
from private_gpt.components.sandbox.content_bundle import BundledFile
from private_gpt.components.sandbox.mount import SandboxMountSpec, VolumeSpec
class SandboxExecutionResult(BaseModel):
"""Result from a sandbox command or code execution."""
model_config = ConfigDict(frozen=True)
success: bool
stdout: str = ""
stderr: str = ""
exit_code: int = 0
execution_time_ms: int = 0
@property
def output(self) -> str:
return self.stdout
@property
def error(self) -> str | None:
return self.stderr or None
@property
def failed(self) -> bool:
return not self.success
class SandboxExecOptions(BaseModel):
timeout: int | None = None
env: dict[str, str] | None = None
cwd: str | None = None
class SandboxCodeOptions(SandboxExecOptions):
language: str = Field(
default="python",
description="Runtime language identifier, for example python, node, or bash.",
)
class SandboxLink(BaseModel):
"""HTTP endpoint for a service running inside a sandbox."""
model_config = ConfigDict(frozen=True)
url: str
headers: dict[str, str] = Field(default_factory=dict)
class SandboxSession(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.
"""
python_executable: str = "python"
@abstractmethod
async def exec(
self, command: str, opts: SandboxExecOptions | None = None
) -> SandboxExecutionResult:
"""Execute a shell command."""
async def run_code(
self, code: str, opts: SandboxCodeOptions | None = None
) -> SandboxExecutionResult:
"""Execute code in a named runtime."""
opts = opts or SandboxCodeOptions()
language = opts.language.lower()
if language in {"bash", "sh", "shell"}:
return await self.exec(code, opts)
return await self.exec(self._command_for_language(language, code), opts)
async def install_package(self, package_name: str) -> SandboxExecutionResult:
return await self.exec(
f"{self.python_executable} -m pip install {package_name}"
)
def _command_for_language(self, language: str, code: str) -> str:
match language:
case "python" | "py":
return f"{self.python_executable} <<'EOF'\n{code}\nEOF"
case "javascript" | "js" | "node" | "typescript" | "ts":
return f"node <<'EOF'\n{code}\nEOF"
case _:
return f"{language} <<'EOF'\n{code}\nEOF"
@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."""
async def remove_mount(self, canonical_path: str) -> None:
"""Remove a mounted directory from the sandbox.
Default: run ``rm -rf`` inside the sandbox, suitable for copy-based
mounts (e.g. Docker). Override when deleting host-backed storage files
would be destructive (e.g. ``BashExecutorSandbox``).
"""
import shlex
normalized = canonical_path.rstrip("/")
await self.exec(f"rm -rf {shlex.quote(normalized)}")
async def get_endpoint(self, port: int) -> SandboxLink | None:
"""Return a browser-consumable URL for a service on the given port.
URI-mode routing lives in the URL path so the link is usable directly via
href/iframe; ``headers`` is empty since browsers can't send routing headers.
Returns None for backends without HTTP ingress (e.g. local process).
"""
return None
@abstractmethod
async def close(self) -> None:
"""Release resources."""
class SandboxProvider(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,
*,
session_id: str | None = None,
volumes: list[VolumeSpec] | None = None,
env: dict[str, str] | None = None,
) -> SandboxSession:
"""Create a sandbox session. The session may be lazy until first use.
``session_id`` tags the backend resource so it can be found again by
restore_session(); ``volumes`` are host directories to bind-mount.
``env`` carries environment variables to inject into the sandbox.
Backends without those capabilities may ignore them.
"""
async def restore_session(
self,
session_id: str,
timeout: int | None = None,
bundle_specs: list[SandboxMountSpec] | None = None,
) -> SandboxSession | None:
"""Reattach to an existing backend sandbox for this session, if any.
Default: the backend cannot restore — returns None.
"""
return None
async def renew_session(self, session: SandboxSession) -> None: # noqa: B027
"""Extend the backend-side lifetime of a live session. Default: no-op."""
async def kill_session(
self, session: SandboxSession, session_id: str | None = None
) -> None:
"""Forcefully release a session's backend resources.
Default: delegate to delete_session().
"""
await self.delete_session(session)
@abstractmethod
async def delete_session(self, session: SandboxSession) -> None:
"""Delete a sandbox session and release all associated resources."""
SandboxProviderFactory = type[SandboxProvider] | Callable[[Settings], SandboxProvider]