349 lines
12 KiB
Python
349 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import abc
|
|
import asyncio
|
|
import io
|
|
import logging
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from typing import Literal, Protocol, runtime_checkable
|
|
from urllib.request import Request, urlopen
|
|
|
|
from ..errors import WorkspaceReadNotFoundError
|
|
from .base_sandbox_session import BaseSandboxSession
|
|
from .events import EventPayloadPolicy, SandboxSessionEvent
|
|
from .utils import event_to_json_line
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DeliveryMode = Literal["sync", "async", "best_effort"]
|
|
OnErrorPolicy = Literal["raise", "log", "ignore"]
|
|
|
|
|
|
def _unwrap_session_wrapper(session: BaseSandboxSession) -> BaseSandboxSession:
|
|
"""
|
|
Defensive unwrapping: if a sink is accidentally bound to a SandboxSession wrapper,
|
|
unwrap to the underlying session to avoid recursive event loops.
|
|
"""
|
|
|
|
# Avoid importing session.sandbox_session.SandboxSession here
|
|
# (would create a dependency cycle).
|
|
cls = type(session)
|
|
if not (
|
|
cls.__name__ == "SandboxSession"
|
|
and cls.__module__ == "agents.sandbox.session.sandbox_session"
|
|
):
|
|
return session
|
|
inner = getattr(session, "_inner", None)
|
|
return inner if isinstance(inner, BaseSandboxSession) else session
|
|
|
|
|
|
class EventSink(abc.ABC):
|
|
"""Consumes SandboxSessionEvent objects (e.g., callback, file outbox, proxy HTTP)."""
|
|
|
|
name: str | None = None
|
|
mode: DeliveryMode
|
|
on_error: OnErrorPolicy
|
|
payload_policy: EventPayloadPolicy | None
|
|
|
|
@abc.abstractmethod
|
|
async def handle(self, event: SandboxSessionEvent) -> None: ...
|
|
|
|
|
|
@runtime_checkable
|
|
class SandboxSessionBoundSink(Protocol):
|
|
"""Optional interface for sinks that need access to the underlying SandboxSession."""
|
|
|
|
def bind(self, session: BaseSandboxSession) -> None: ...
|
|
|
|
|
|
class CallbackSink(EventSink):
|
|
"""Deliver events to a user-provided callable.
|
|
|
|
Supports sync or async callables.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
callback: Callable[[SandboxSessionEvent, BaseSandboxSession], object],
|
|
*,
|
|
mode: DeliveryMode = "sync",
|
|
on_error: OnErrorPolicy = "raise",
|
|
payload_policy: EventPayloadPolicy | None = None,
|
|
name: str | None = None,
|
|
) -> None:
|
|
self._callback = callback
|
|
self.mode = mode
|
|
self.on_error = on_error
|
|
self.payload_policy = payload_policy
|
|
self._session: BaseSandboxSession | None = None
|
|
self.name = name
|
|
|
|
def bind(self, session: BaseSandboxSession) -> None:
|
|
self._session = _unwrap_session_wrapper(session)
|
|
|
|
async def handle(self, event: SandboxSessionEvent) -> None:
|
|
if self._session is None:
|
|
raise RuntimeError(
|
|
"CallbackSink requires a bound session; use SandboxSession / "
|
|
"a sandbox client with instrumentation (or call bind(session))."
|
|
)
|
|
out = self._callback(event, self._session)
|
|
if asyncio.iscoroutine(out):
|
|
await out
|
|
|
|
|
|
class JsonlOutboxSink(EventSink):
|
|
"""Append events to a JSONL file on the host filesystem."""
|
|
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
*,
|
|
mode: DeliveryMode = "best_effort",
|
|
on_error: OnErrorPolicy = "log",
|
|
payload_policy: EventPayloadPolicy | None = None,
|
|
) -> None:
|
|
self.path = path
|
|
self.mode = mode
|
|
self.on_error = on_error
|
|
self.payload_policy = payload_policy
|
|
|
|
async def handle(self, event: SandboxSessionEvent) -> None:
|
|
line = event_to_json_line(event)
|
|
await asyncio.to_thread(self._append_line, line)
|
|
|
|
def _append_line(self, line: str) -> None:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
fcntl_mod: ModuleType | None
|
|
try:
|
|
import fcntl as fcntl_mod
|
|
except Exception:
|
|
# Not available on all platforms (e.g. Windows)
|
|
fcntl_mod = None
|
|
|
|
with self.path.open("a", encoding="utf-8") as f:
|
|
if fcntl_mod is not None:
|
|
try:
|
|
fcntl_mod.flock(f.fileno(), fcntl_mod.LOCK_EX)
|
|
except Exception:
|
|
pass
|
|
f.write(line)
|
|
f.flush()
|
|
if fcntl_mod is not None:
|
|
try:
|
|
# Nice to have release here; the OS releases the lock
|
|
# automatically when the file is closed.
|
|
fcntl_mod.flock(f.fileno(), fcntl_mod.LOCK_UN)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class WorkspaceJsonlSink(EventSink):
|
|
"""
|
|
Append events to a JSONL file inside the session workspace (under manifest.root).
|
|
|
|
This sink still runs in the client process, but writes into the session via
|
|
`SandboxSession.write()`, so it works across sandboxes (Docker/Modal)
|
|
without requiring host-mounted volumes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
workspace_relpath: Path = Path("logs/events-{session_id}.jsonl"),
|
|
ephemeral: bool = False,
|
|
mode: DeliveryMode = "best_effort",
|
|
on_error: OnErrorPolicy = "log",
|
|
payload_policy: EventPayloadPolicy | None = None,
|
|
flush_every: int = 1,
|
|
) -> None:
|
|
"""
|
|
Args:
|
|
workspace_relpath: Relative path under the session workspace root.
|
|
This also supports lightweight templating which is expanded on `bind()`:
|
|
- `"{session_id}"` (UUID string, e.g. "550e8400-e29b-41d4-a716-446655440000")
|
|
- `"{session_id_hex}"` (UUID hex, e.g. "550e8400e29b41d4a716446655440000")
|
|
|
|
Example:
|
|
Path("logs/events-{session_id}.jsonl")
|
|
"""
|
|
self.workspace_relpath = workspace_relpath
|
|
self.ephemeral = ephemeral
|
|
self.mode = mode
|
|
self.on_error = on_error
|
|
self.payload_policy = payload_policy
|
|
self._session: BaseSandboxSession | None = None
|
|
self._resolved_workspace_relpath: Path | None = None
|
|
self._buf = bytearray()
|
|
self._seen = 0
|
|
self._lock = asyncio.Lock()
|
|
self._flush_every = max(1, int(flush_every))
|
|
|
|
def _resolve_relpath(self) -> Path:
|
|
rel = self.workspace_relpath
|
|
if self._session is None:
|
|
return rel
|
|
template = str(rel)
|
|
try:
|
|
rendered = template.format(
|
|
session_id=self._session.state.session_id,
|
|
session_id_hex=self._session.state.session_id.hex,
|
|
)
|
|
except Exception:
|
|
# If formatting fails for any reason, fall back to the literal path.
|
|
rendered = template
|
|
return Path(rendered)
|
|
|
|
def bind(self, session: BaseSandboxSession) -> None:
|
|
self._session = _unwrap_session_wrapper(session)
|
|
self._resolved_workspace_relpath = self._resolve_relpath()
|
|
if self.ephemeral:
|
|
relpath = self._resolved_workspace_relpath or self.workspace_relpath
|
|
self._session.register_persist_workspace_skip_path(relpath)
|
|
|
|
def _buffer_event(self, event: SandboxSessionEvent) -> bool:
|
|
self._buf.extend(event_to_json_line(event).encode("utf-8"))
|
|
self._seen += 1
|
|
|
|
if self._seen % self._flush_every == 0:
|
|
return True
|
|
if event.op == "persist_workspace" and event.phase == "start":
|
|
return True
|
|
if event.op == "stop":
|
|
return True
|
|
if event.op == "shutdown" and event.phase == "start":
|
|
return True
|
|
if event.op == "shutdown" and event.phase == "finish":
|
|
return False
|
|
|
|
return False
|
|
|
|
async def _can_flush_to_workspace(self) -> bool:
|
|
if self._session is None:
|
|
return False
|
|
|
|
# `SandboxSession.start()` emits the `start` event before the underlying sandbox
|
|
# is fully running, so writes may still fail during early startup or late teardown.
|
|
try:
|
|
return await self._session.running()
|
|
except Exception:
|
|
return False
|
|
|
|
async def _flush_buffer(self) -> None:
|
|
if self._session is None or not self._buf:
|
|
return
|
|
|
|
relpath = self._resolved_workspace_relpath or self.workspace_relpath
|
|
existing = await self._read_existing_outbox(relpath)
|
|
pending = bytes(self._buf)
|
|
await self._session.write(relpath, io.BytesIO(existing + pending))
|
|
self._buf.clear()
|
|
|
|
async def _read_existing_outbox(self, relpath: Path) -> bytes:
|
|
if self._session is None:
|
|
return b""
|
|
|
|
try:
|
|
existing = await self._session.read(relpath)
|
|
except (FileNotFoundError, WorkspaceReadNotFoundError):
|
|
return b""
|
|
|
|
try:
|
|
payload = existing.read()
|
|
finally:
|
|
existing.close()
|
|
|
|
if isinstance(payload, str):
|
|
return payload.encode("utf-8")
|
|
return bytes(payload)
|
|
|
|
async def handle(self, event: SandboxSessionEvent) -> None:
|
|
# If unbound (e.g., audit event emission used without a SandboxSession wrapper),
|
|
# no-op.
|
|
if self._session is None:
|
|
return
|
|
|
|
async with self._lock:
|
|
if not self._buffer_event(event):
|
|
return
|
|
|
|
if not await self._can_flush_to_workspace():
|
|
return
|
|
|
|
await self._flush_buffer()
|
|
|
|
|
|
class HttpProxySink(EventSink):
|
|
"""POST events as JSON to a proxy endpoint (local daemon or remote service)."""
|
|
|
|
def __init__(
|
|
self,
|
|
endpoint: str,
|
|
*,
|
|
headers: dict[str, str] | None = None,
|
|
timeout_s: float = 5.0,
|
|
spool_path: Path | None = None,
|
|
mode: DeliveryMode = "best_effort",
|
|
on_error: OnErrorPolicy = "log",
|
|
payload_policy: EventPayloadPolicy | None = None,
|
|
) -> None:
|
|
self.endpoint = endpoint
|
|
self.headers = headers or {}
|
|
self.timeout_s = timeout_s
|
|
self.spool_path = spool_path
|
|
self.mode = mode
|
|
self.on_error = on_error
|
|
self.payload_policy = payload_policy
|
|
|
|
async def handle(self, event: SandboxSessionEvent) -> None:
|
|
payload = event.model_dump_json().encode("utf-8")
|
|
spool_line = event_to_json_line(event) if self.spool_path is not None else None
|
|
await asyncio.to_thread(self._post, payload, spool_line)
|
|
|
|
def _post(self, body: bytes, spool_line: str | None) -> None:
|
|
# TODO: thinking about using proxy instead of direct http call
|
|
req = Request(
|
|
self.endpoint,
|
|
data=body,
|
|
headers={"content-type": "application/json", **self.headers},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urlopen(req, timeout=self.timeout_s) as resp:
|
|
_ = resp.read(1) # ensure request completes
|
|
except OSError as e:
|
|
if spool_line is not None and self.spool_path is not None:
|
|
try:
|
|
self.spool_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with self.spool_path.open("a", encoding="utf-8") as f:
|
|
f.write(spool_line)
|
|
f.flush()
|
|
except Exception:
|
|
pass
|
|
raise RuntimeError(f"http proxy sink POST failed: {e}") from e
|
|
|
|
|
|
class ChainedSink(EventSink):
|
|
"""
|
|
Groups multiple sinks that should run in order.
|
|
|
|
Note: Instrumentation unwraps this group and applies per-op/per-sink
|
|
payload policies to each inner sink individually (so grouping does not disable
|
|
per-sink policy behavior).
|
|
"""
|
|
|
|
def __init__(self, *sinks: EventSink) -> None:
|
|
self.sinks = list(sinks)
|
|
# These are not used directly when Instrumentation unwraps the
|
|
# group, but keep the object conforming to EventSink.
|
|
self.mode = "sync"
|
|
self.on_error = "raise"
|
|
self.payload_policy = None
|
|
|
|
async def handle(self, event: SandboxSessionEvent) -> None:
|
|
# Fallback behavior if used directly (without Instrumentation unwrapping).
|
|
for sink in self.sinks:
|
|
await sink.handle(event)
|