mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-08 23:06:03 +00:00
feat(box): add sandbox_exec tool loop for local-agent calculations
This commit is contained in:
128
src/langbot/pkg/box/runtime.py
Normal file
128
src/langbot/pkg/box/runtime.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from .backend import BaseSandboxBackend, DockerBackend, PodmanBackend
|
||||
from .errors import BoxBackendUnavailableError, BoxSessionConflictError
|
||||
from .models import BoxExecutionResult, BoxExecutionStatus, BoxSessionInfo, BoxSpec
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True)
|
||||
class _RuntimeSession:
|
||||
info: BoxSessionInfo
|
||||
lock: asyncio.Lock
|
||||
|
||||
|
||||
class BoxRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
logger: logging.Logger,
|
||||
backends: list[BaseSandboxBackend] | None = None,
|
||||
session_ttl_sec: int = 300,
|
||||
):
|
||||
self.logger = logger
|
||||
self.backends = backends or [PodmanBackend(logger), DockerBackend(logger)]
|
||||
self.session_ttl_sec = session_ttl_sec
|
||||
self._backend: BaseSandboxBackend | None = None
|
||||
self._sessions: dict[str, _RuntimeSession] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def initialize(self):
|
||||
self._backend = await self._select_backend()
|
||||
|
||||
async def execute(self, spec: BoxSpec) -> BoxExecutionResult:
|
||||
session = await self._get_or_create_session(spec)
|
||||
|
||||
async with session.lock:
|
||||
result = await (await self._get_backend()).exec(session.info, spec)
|
||||
|
||||
async with self._lock:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
if spec.session_id in self._sessions:
|
||||
self._sessions[spec.session_id].info.last_used_at = now
|
||||
|
||||
if result.status == BoxExecutionStatus.TIMED_OUT:
|
||||
await self._drop_session_locked(spec.session_id)
|
||||
|
||||
return result
|
||||
|
||||
async def shutdown(self):
|
||||
async with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
for session_id in session_ids:
|
||||
await self._drop_session_locked(session_id)
|
||||
|
||||
async def _get_or_create_session(self, spec: BoxSpec) -> _RuntimeSession:
|
||||
async with self._lock:
|
||||
await self._reap_expired_sessions_locked()
|
||||
|
||||
existing = self._sessions.get(spec.session_id)
|
||||
if existing is not None:
|
||||
self._assert_session_compatible(existing.info, spec)
|
||||
existing.info.last_used_at = dt.datetime.now(dt.UTC)
|
||||
return existing
|
||||
|
||||
backend = await self._get_backend()
|
||||
info = await backend.start_session(spec)
|
||||
runtime_session = _RuntimeSession(info=info, lock=asyncio.Lock())
|
||||
self._sessions[spec.session_id] = runtime_session
|
||||
return runtime_session
|
||||
|
||||
async def _get_backend(self) -> BaseSandboxBackend:
|
||||
if self._backend is None:
|
||||
self._backend = await self._select_backend()
|
||||
if self._backend is None:
|
||||
raise BoxBackendUnavailableError(
|
||||
'LangBot Box backend unavailable. Install and start Podman or Docker before using sandbox_exec.'
|
||||
)
|
||||
return self._backend
|
||||
|
||||
async def _select_backend(self) -> BaseSandboxBackend | None:
|
||||
for backend in self.backends:
|
||||
try:
|
||||
await backend.initialize()
|
||||
if await backend.is_available():
|
||||
self.logger.info(f'LangBot Box using backend: {backend.name}')
|
||||
return backend
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'LangBot Box backend {backend.name} probe failed: {exc}')
|
||||
|
||||
self.logger.warning('LangBot Box backend unavailable: neither Podman nor Docker is ready')
|
||||
return None
|
||||
|
||||
async def _reap_expired_sessions_locked(self):
|
||||
if self.session_ttl_sec <= 0:
|
||||
return
|
||||
|
||||
deadline = dt.datetime.now(dt.UTC) - dt.timedelta(seconds=self.session_ttl_sec)
|
||||
expired_session_ids = [
|
||||
session_id
|
||||
for session_id, session in self._sessions.items()
|
||||
if session.info.last_used_at < deadline
|
||||
]
|
||||
|
||||
for session_id in expired_session_ids:
|
||||
await self._drop_session_locked(session_id)
|
||||
|
||||
async def _drop_session_locked(self, session_id: str):
|
||||
runtime_session = self._sessions.pop(session_id, None)
|
||||
if runtime_session is None or self._backend is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._backend.stop_session(runtime_session.info)
|
||||
except Exception as exc:
|
||||
self.logger.warning(f'Failed to clean up box session {session_id}: {exc}')
|
||||
|
||||
def _assert_session_compatible(self, session: BoxSessionInfo, spec: BoxSpec):
|
||||
if session.network != spec.network:
|
||||
raise BoxSessionConflictError(
|
||||
f'sandbox_exec session {spec.session_id} already exists with network={session.network.value}'
|
||||
)
|
||||
if session.image != spec.image:
|
||||
raise BoxSessionConflictError(
|
||||
f'sandbox_exec session {spec.session_id} already exists with image={session.image}'
|
||||
)
|
||||
Reference in New Issue
Block a user