mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-08-22 07:13:39 +00:00
feat(sandbox): introduce sandbox operation timeout configuration and refactor execution handling
- Added `SANDBOX_OPERATION_TIMEOUT_SECONDS` to configuration for controlling sandbox operation timeouts. - Updated various components to utilize the new timeout setting, ensuring consistent timeout behavior across different sandbox providers. - Refactored execution logic in the sandbox tools to improve error handling and cleanup processes. - Enhanced tests to validate the new timeout functionality and ensure proper execution flow.
This commit is contained in:
parent
deb67fbad7
commit
8ac898eccb
14 changed files with 417 additions and 49 deletions
|
|
@ -368,6 +368,8 @@ STT_SERVICE=local/base
|
|||
# only so many at once (over the cap the tool returns a retry-shortly error).
|
||||
# SANDBOX_IDLE_TTL_SECONDS=900
|
||||
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
|
||||
# Maximum wall-clock budget for one sandbox operation.
|
||||
# SANDBOX_OPERATION_TIMEOUT_SECONDS=200
|
||||
# Largest file the agent may pull out of a sandbox into an artifact (30 MiB).
|
||||
# ARTIFACT_MAX_FILE_BYTES=31457280
|
||||
|
||||
|
|
@ -391,8 +393,6 @@ STT_SERVICE=local/base
|
|||
# OPENSANDBOX_SERVER_MEMORY_LIMIT=512m
|
||||
# Tracks SURFSENSE_VERSION by default; set only to run your own sandbox image.
|
||||
# SANDBOX_IMAGE=
|
||||
# Creation waits on the image pull when the compose pull did not warm the cache.
|
||||
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# External API Keys (optional)
|
||||
|
|
|
|||
|
|
@ -546,15 +546,15 @@ EMBEDDING_CACHE_ENABLED=false
|
|||
# a reachable server (localhost:8080 for the compose one) or set this FALSE.
|
||||
# SANDBOX_ENABLED=TRUE
|
||||
# SANDBOX_PROVIDER=opensandbox
|
||||
# SANDBOX_IDLE_TTL_SECONDS=900
|
||||
# SANDBOX_MAX_SESSIONS_PER_WORKSPACE=2
|
||||
# SANDBOX_IDLE_TTL_SECONDS=900
|
||||
# SANDBOX_OPERATION_TIMEOUT_SECONDS=200
|
||||
# ARTIFACT_MAX_FILE_BYTES=31457280
|
||||
|
||||
# OpenSandbox (self-hosted). Host-run backend against compose: localhost:8080.
|
||||
# OPENSANDBOX_DOMAIN=opensandbox-server:8080
|
||||
# OPENSANDBOX_API_KEY=surfsense-dev-sandbox
|
||||
# SANDBOX_IMAGE=ghcr.io/modsetter/surfsense-sandbox:latest
|
||||
# SANDBOX_REQUEST_TIMEOUT_SECONDS=120
|
||||
|
||||
# Daytona (cloud)
|
||||
# DAYTONA_API_KEY=your-daytona-api-key
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from langchain.tools import ToolRuntime
|
|||
from app.agents.chat.multi_agent_chat.shared.state.filesystem_state import (
|
||||
SurfSenseFilesystemState,
|
||||
)
|
||||
from app.config import config as app_config
|
||||
from app.sandbox import ExecResult, SandboxUnavailableError, get_registry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -23,7 +24,7 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_EXECUTE_TIMEOUT = 300
|
||||
MAX_EXECUTE_TIMEOUT = app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
async def execute_in_sandbox(
|
||||
|
|
@ -34,8 +35,9 @@ async def execute_in_sandbox(
|
|||
) -> str:
|
||||
"""Top-level entry: run *command* as Python, retrying once."""
|
||||
assert mw._thread_id is not None
|
||||
effective_timeout = timeout or MAX_EXECUTE_TIMEOUT
|
||||
try:
|
||||
return _format(await _run(mw, command, timeout))
|
||||
return _format(await _run(mw, command, effective_timeout))
|
||||
except SandboxUnavailableError as err:
|
||||
return f"Error: {err}"
|
||||
except TimeoutError:
|
||||
|
|
@ -43,7 +45,7 @@ async def execute_in_sandbox(
|
|||
# the kernel until the sandbox expires. Upgrade path is an interrupt
|
||||
# call on the execution id once the SDK exposes one for kernel runs.
|
||||
return (
|
||||
f"Error: execution exceeded {timeout or MAX_EXECUTE_TIMEOUT}s and was "
|
||||
f"Error: execution exceeded {effective_timeout}s and was "
|
||||
"abandoned. The interpreter may still be busy; simplify the code."
|
||||
)
|
||||
except Exception as first_err:
|
||||
|
|
@ -57,14 +59,14 @@ async def execute_in_sandbox(
|
|||
# may have a wedged kernel, and reconnecting would inherit it.
|
||||
registry = await get_registry()
|
||||
await registry.terminate(mw._thread_id)
|
||||
return _format(await _run(mw, command, timeout))
|
||||
return _format(await _run(mw, command, effective_timeout))
|
||||
except Exception:
|
||||
logger.exception("Sandbox retry also failed for thread %s", mw._thread_id)
|
||||
return "Error: Code execution is temporarily unavailable. Please try again."
|
||||
|
||||
|
||||
async def _run(
|
||||
mw: SurfSenseFilesystemMiddleware, code: str, timeout: int | None
|
||||
mw: SurfSenseFilesystemMiddleware, code: str, timeout: int
|
||||
) -> ExecResult:
|
||||
registry = await get_registry()
|
||||
# Without a workspace every such thread would share one cap bucket and
|
||||
|
|
@ -73,7 +75,7 @@ async def _run(
|
|||
session = await registry.get_session(mw._thread_id, workspace_id)
|
||||
return await asyncio.wait_for(
|
||||
session.execute(code, language="python"),
|
||||
timeout=timeout or MAX_EXECUTE_TIMEOUT,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
|
@ -11,11 +13,15 @@ from langchain_core.tools import BaseTool, tool
|
|||
|
||||
from app.capabilities.core import ActivityDescriptor
|
||||
from app.config import config as app_config
|
||||
from app.sandbox import SandboxSession, get_registry
|
||||
from app.sandbox import ExecResult, SandboxSession, get_registry
|
||||
|
||||
from .thread_resolver import resolve_root_thread_id
|
||||
|
||||
_MAX_CONTEXT_CHARS = 16_000
|
||||
_PROCESS_TERMINATION_GRACE_SECONDS = 5
|
||||
_TRANSPORT_GRACE_SECONDS = 5
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_session(workspace_id: int, runtime: ToolRuntime) -> SandboxSession:
|
||||
|
|
@ -30,6 +36,57 @@ def _result_text(output: str, exit_code: int, *, full_output_path: str | None) -
|
|||
return output + suffix
|
||||
|
||||
|
||||
async def _run_python_script(
|
||||
session: SandboxSession,
|
||||
code: str,
|
||||
) -> ExecResult:
|
||||
"""Materialize agent-authored code and run it as one bounded process."""
|
||||
script_path = f"/tmp/.surfsense-exec-{uuid.uuid4().hex}.py"
|
||||
quoted_script = shlex.quote(script_path)
|
||||
operation_timeout = app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
|
||||
process_timeout = max(
|
||||
1,
|
||||
operation_timeout
|
||||
- _PROCESS_TERMINATION_GRACE_SECONDS
|
||||
- _TRANSPORT_GRACE_SECONDS,
|
||||
)
|
||||
await session.write_file(script_path, code.encode())
|
||||
command = (
|
||||
f"script={quoted_script}; "
|
||||
"""trap 'rm -f -- "$script"' EXIT; """
|
||||
"cd -- /workspace && "
|
||||
"timeout --signal=TERM "
|
||||
f"--kill-after={_PROCESS_TERMINATION_GRACE_SECONDS}s {process_timeout}s "
|
||||
'python3 "$script"'
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(operation_timeout):
|
||||
result = await session.run_command(command)
|
||||
except TimeoutError:
|
||||
raise TimeoutError(
|
||||
f"Sandbox Python execution exceeded {operation_timeout} seconds"
|
||||
) from None
|
||||
finally:
|
||||
# The shell trap handles normal completion and provider-stream failures
|
||||
# after process exit. This fallback covers failures before the shell starts.
|
||||
try:
|
||||
async with asyncio.timeout(
|
||||
min(_PROCESS_TERMINATION_GRACE_SECONDS, operation_timeout)
|
||||
):
|
||||
await session.run_command(f"rm -f -- {quoted_script}")
|
||||
except Exception:
|
||||
logger.warning("Could not remove temporary sandbox script", exc_info=True)
|
||||
|
||||
if result.exit_code == 124:
|
||||
detail = f"Python execution exceeded {process_timeout} seconds"
|
||||
return ExecResult(
|
||||
output=f"{result.output}\n{detail}".lstrip(),
|
||||
exit_code=result.exit_code,
|
||||
truncated=result.truncated,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def create_sandbox_tools(*, workspace_id: int) -> list[BaseTool]:
|
||||
"""Build the provider-agnostic authoring tools."""
|
||||
|
||||
|
|
@ -41,13 +98,13 @@ def create_sandbox_tools(*, workspace_id: int) -> list[BaseTool]:
|
|||
) -> str:
|
||||
"""Run Python or a Bash command in the sandbox.
|
||||
|
||||
Write multi-step work to a source file and run that file: only some
|
||||
providers keep interpreter state between calls. Long output is
|
||||
truncated here and written in full to the returned sandbox path.
|
||||
Each Python call runs as a fresh process; carry state between calls in
|
||||
files, not interpreter variables. Long output is truncated here and
|
||||
written in full to the returned sandbox path.
|
||||
"""
|
||||
session = await _get_session(workspace_id, runtime)
|
||||
result = (
|
||||
await session.execute(code_or_command, language="python")
|
||||
await _run_python_script(session, code_or_command)
|
||||
if language == "python"
|
||||
else await session.run_command(code_or_command)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -572,8 +572,8 @@ class Config:
|
|||
# Creation blocks server-side while the image is pulled onto the host
|
||||
# daemon, which the SDK's 30s default turns into an error rather than a slow
|
||||
# first request. Compose pre-pulls; this covers hosts that did not.
|
||||
SANDBOX_REQUEST_TIMEOUT_SECONDS = int(
|
||||
os.getenv("SANDBOX_REQUEST_TIMEOUT_SECONDS", "120")
|
||||
SANDBOX_OPERATION_TIMEOUT_SECONDS = int(
|
||||
os.getenv("SANDBOX_OPERATION_TIMEOUT_SECONDS", "200")
|
||||
)
|
||||
SANDBOX_MAX_SESSIONS_PER_WORKSPACE = int(
|
||||
os.getenv("SANDBOX_MAX_SESSIONS_PER_WORKSPACE", "2")
|
||||
|
|
|
|||
|
|
@ -29,16 +29,11 @@ from ..protocol import ExecResult
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
THREAD_LABEL_KEY = "surfsense_thread"
|
||||
_DEFAULT_TIMEOUT = 300
|
||||
_START_TIMEOUT = 60
|
||||
|
||||
|
||||
def _wrap_as_python(code: str) -> str:
|
||||
"""Wrap code in a unique-sentinel heredoc.
|
||||
|
||||
Daytona exposes commands, not a kernel, so Python arrives as a shell
|
||||
command and state does not carry across calls.
|
||||
"""
|
||||
"""Wrap code in a unique-sentinel heredoc for Daytona's command API."""
|
||||
sentinel = f"_PYEOF_{secrets.token_hex(8)}"
|
||||
return f"python3 << '{sentinel}'\n{code}\n{sentinel}"
|
||||
|
||||
|
|
@ -58,7 +53,10 @@ class DaytonaSession:
|
|||
|
||||
async def run_command(self, command: str) -> ExecResult:
|
||||
def _run() -> ExecResult:
|
||||
result = self._sandbox.process.exec(command, timeout=_DEFAULT_TIMEOUT)
|
||||
result = self._sandbox.process.exec(
|
||||
command,
|
||||
timeout=app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
return ExecResult(
|
||||
output=result.result or "",
|
||||
exit_code=result.exit_code or 0,
|
||||
|
|
|
|||
|
|
@ -27,9 +27,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
THREAD_METADATA_KEY = "surfsense_thread"
|
||||
|
||||
# The image's entrypoint is inherited, but OpenSandbox only starts the Jupyter
|
||||
# kernel when it is passed explicitly; PYTHON_VERSION selects which interpreter
|
||||
# the kernel binds to (see docker/sandbox/Dockerfile).
|
||||
# OpenSandbox requires the image service entrypoint explicitly; PYTHON_VERSION
|
||||
# selects the image's Python runtime (see docker/sandbox/Dockerfile).
|
||||
_ENTRYPOINT = ["/opt/code-interpreter/code-interpreter.sh"]
|
||||
_ENV = {"PYTHON_VERSION": "3.12"}
|
||||
_RUNNING_STATES = {"RUNNING", "PENDING"}
|
||||
|
|
@ -68,12 +67,7 @@ def _raise_normalized(
|
|||
|
||||
|
||||
def _to_result(execution) -> ExecResult:
|
||||
"""Flatten an SDK Execution into the protocol shape.
|
||||
|
||||
`exit_code` is only populated for commands; kernel executions report failure
|
||||
through `error`, so an errored run has to be mapped to a non-zero code for
|
||||
callers that only check `ok`.
|
||||
"""
|
||||
"""Flatten an SDK execution into the provider-neutral result shape."""
|
||||
if execution.error is not None:
|
||||
return ExecResult(output=str(execution), exit_code=1)
|
||||
return ExecResult(output=str(execution), exit_code=execution.exit_code or 0)
|
||||
|
|
@ -95,8 +89,6 @@ class OpenSandboxSession:
|
|||
return self._sandbox.id
|
||||
|
||||
async def _get_interpreter(self) -> CodeInterpreter:
|
||||
# Created lazily and once: the kernel's cold start is ~5 s, and its
|
||||
# whole value is that later executions share its process state.
|
||||
async with self._interpreter_mu:
|
||||
if self._interpreter is None:
|
||||
self._interpreter = await CodeInterpreter.create(sandbox=self._sandbox)
|
||||
|
|
@ -164,7 +156,7 @@ class OpenSandboxProvider:
|
|||
# blocks on the server pulling the image when it is not already on
|
||||
# the host daemon.
|
||||
request_timeout=timedelta(
|
||||
seconds=app_config.SANDBOX_REQUEST_TIMEOUT_SECONDS
|
||||
seconds=app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
|
||||
),
|
||||
)
|
||||
self._ttl = app_config.SANDBOX_IDLE_TTL_SECONDS
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.tools.sandbox import (
|
||||
_run_python_script,
|
||||
)
|
||||
from app.agents.chat.multi_agent_chat.subagents.builtins.deliverables.tools.save_artifact import (
|
||||
_read_artifact_file,
|
||||
)
|
||||
|
|
@ -48,7 +51,7 @@ pytestmark = [
|
|||
)
|
||||
],
|
||||
)
|
||||
async def test_opensandbox_persistent_kernel_binary_io_and_terminate(
|
||||
async def test_opensandbox_one_shot_python_binary_io_and_terminate(
|
||||
monkeypatch, skill, prompt, expected_mime, expected_evidence_steps
|
||||
):
|
||||
monkeypatch.setattr(app_config, "OPENSANDBOX_DOMAIN", "localhost:8080")
|
||||
|
|
@ -62,9 +65,10 @@ async def test_opensandbox_persistent_kernel_binary_io_and_terminate(
|
|||
session = await provider.get_or_create_session(thread_id)
|
||||
try:
|
||||
evidence: list[str] = []
|
||||
first = await session.execute("contract_value = 41\nprint(contract_value)")
|
||||
second = await session.execute("print(contract_value + 1)")
|
||||
pdf = await session.execute(
|
||||
first = await _run_python_script(session, "print(41)")
|
||||
second = await _run_python_script(session, "print(42)")
|
||||
pdf = await _run_python_script(
|
||||
session,
|
||||
"""
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
|
|
@ -144,7 +148,8 @@ Packer.toBuffer(doc).then((buffer) => fs.writeFileSync("/tmp/report.docx", buffe
|
|||
"""
|
||||
generated = await session.run_command(f"node -e {shlex.quote(javascript)}")
|
||||
else:
|
||||
generated = await session.execute(
|
||||
generated = await _run_python_script(
|
||||
session,
|
||||
"""
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from unittest.mock import Mock
|
|||
import pytest
|
||||
from daytona import Daytona, SandboxState
|
||||
|
||||
from app.config import config as app_config
|
||||
from app.sandbox.providers.daytona import (
|
||||
THREAD_LABEL_KEY,
|
||||
DaytonaProvider,
|
||||
|
|
@ -33,6 +34,10 @@ async def test_daytona_session_maps_command_and_binary_file_operations():
|
|||
assert result.ok
|
||||
assert result.output == "ok"
|
||||
assert "python3 <<" in sandbox.process.exec.call_args.args[0]
|
||||
assert (
|
||||
sandbox.process.exec.call_args.kwargs["timeout"]
|
||||
== app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
|
||||
)
|
||||
assert downloaded == b"\x00binary"
|
||||
sandbox.fs.upload_file.assert_called_once_with(b"new", "/workspace/file.bin")
|
||||
client.delete.assert_called_once_with(sandbox)
|
||||
|
|
|
|||
|
|
@ -498,13 +498,131 @@ async def test_load_artifact_for_revision_writes_primary_and_markdown(monkeypatc
|
|||
assert resolved_backends == ["azure"]
|
||||
|
||||
|
||||
async def test_execute_python_uses_unique_one_shot_scripts_and_cleans_up(monkeypatch):
|
||||
commands: list[str] = []
|
||||
session = FakeSandboxSession({})
|
||||
|
||||
async def run_command(command: str) -> ExecResult:
|
||||
commands.append(command)
|
||||
return ExecResult("created", 0)
|
||||
|
||||
session.run_command = run_command # type: ignore[method-assign]
|
||||
ids = iter(("first", "second"))
|
||||
monkeypatch.setattr(
|
||||
sandbox_tools.uuid,
|
||||
"uuid4",
|
||||
lambda: SimpleNamespace(hex=next(ids)),
|
||||
)
|
||||
|
||||
async def get_session(*_args):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(sandbox_tools, "_get_session", get_session)
|
||||
tool = next(
|
||||
tool
|
||||
for tool in sandbox_tools.create_sandbox_tools(workspace_id=3)
|
||||
if tool.name == "execute"
|
||||
)
|
||||
|
||||
first = await tool.coroutine(
|
||||
code_or_command="print('first')", language="python", runtime=_runtime()
|
||||
)
|
||||
second = await tool.coroutine(
|
||||
code_or_command="print('second')", language="python", runtime=_runtime()
|
||||
)
|
||||
|
||||
assert "created" in first
|
||||
assert "created" in second
|
||||
assert session.writes == {
|
||||
"/tmp/.surfsense-exec-first.py": b"print('first')",
|
||||
"/tmp/.surfsense-exec-second.py": b"print('second')",
|
||||
}
|
||||
execution_commands = [command for command in commands if command.startswith("script=")]
|
||||
assert len(execution_commands) == 2
|
||||
assert "/tmp/.surfsense-exec-first.py" in execution_commands[0]
|
||||
assert "/tmp/.surfsense-exec-second.py" in execution_commands[1]
|
||||
assert all("cd -- /workspace" in command for command in execution_commands)
|
||||
assert all(
|
||||
"timeout --signal=TERM --kill-after=5s" in command
|
||||
for command in execution_commands
|
||||
)
|
||||
assert [command for command in commands if command.startswith("rm -f --")] == [
|
||||
"rm -f -- /tmp/.surfsense-exec-first.py",
|
||||
"rm -f -- /tmp/.surfsense-exec-second.py",
|
||||
]
|
||||
|
||||
|
||||
async def test_execute_python_cleans_up_when_command_fails(monkeypatch):
|
||||
commands: list[str] = []
|
||||
session = FakeSandboxSession({})
|
||||
|
||||
async def run_command(command: str) -> ExecResult:
|
||||
commands.append(command)
|
||||
if command.startswith("script="):
|
||||
raise RuntimeError("provider failed")
|
||||
return ExecResult("", 0)
|
||||
|
||||
session.run_command = run_command # type: ignore[method-assign]
|
||||
monkeypatch.setattr(
|
||||
sandbox_tools.uuid,
|
||||
"uuid4",
|
||||
lambda: SimpleNamespace(hex="failed"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="provider failed"):
|
||||
await sandbox_tools._run_python_script(session, "raise RuntimeError")
|
||||
|
||||
assert commands[-1] == "rm -f -- /tmp/.surfsense-exec-failed.py"
|
||||
|
||||
|
||||
async def test_execute_python_returns_before_provider_stream_can_wedge(monkeypatch):
|
||||
session = FakeSandboxSession({})
|
||||
|
||||
async def run_command(command: str) -> ExecResult:
|
||||
if command.startswith("rm -f --"):
|
||||
return ExecResult("", 0)
|
||||
await asyncio.sleep(1)
|
||||
return ExecResult("", 0)
|
||||
|
||||
session.run_command = run_command # type: ignore[method-assign]
|
||||
monkeypatch.setattr(
|
||||
sandbox_tools.app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 0.01
|
||||
)
|
||||
|
||||
with pytest.raises(TimeoutError, match="exceeded"):
|
||||
await sandbox_tools._run_python_script(session, "print('never returned')")
|
||||
|
||||
|
||||
async def test_execute_python_reports_process_timeout(monkeypatch):
|
||||
session = FakeSandboxSession({})
|
||||
|
||||
async def run_command(command: str) -> ExecResult:
|
||||
return (
|
||||
ExecResult("partial output", 124)
|
||||
if command.startswith("script=")
|
||||
else ExecResult("", 0)
|
||||
)
|
||||
|
||||
session.run_command = run_command # type: ignore[method-assign]
|
||||
monkeypatch.setattr(
|
||||
sandbox_tools.app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 27
|
||||
)
|
||||
|
||||
result = await sandbox_tools._run_python_script(session, "while True: pass")
|
||||
|
||||
assert result.exit_code == 124
|
||||
assert result.output == "partial output\nPython execution exceeded 17 seconds"
|
||||
|
||||
|
||||
async def test_execute_truncates_and_preserves_full_output(monkeypatch):
|
||||
session = _sandbox({})
|
||||
session = FakeSandboxSession({})
|
||||
|
||||
async def execute(code: str, language: str = "python") -> ExecResult:
|
||||
return ExecResult("x" * (sandbox_tools._MAX_CONTEXT_CHARS + 1), 0)
|
||||
async def run_command(command: str) -> ExecResult:
|
||||
if command.startswith("script="):
|
||||
return ExecResult("x" * (sandbox_tools._MAX_CONTEXT_CHARS + 1), 0)
|
||||
return ExecResult("", 0)
|
||||
|
||||
session.execute = execute # type: ignore[attr-defined]
|
||||
session.run_command = run_command # type: ignore[method-assign]
|
||||
|
||||
async def get_session(*_args):
|
||||
return session
|
||||
|
|
@ -522,7 +640,35 @@ async def test_execute_truncates_and_preserves_full_output(monkeypatch):
|
|||
|
||||
assert "output truncated" in result
|
||||
assert "Full output:" in result
|
||||
assert next(iter(session.writes.values())).endswith(b"x")
|
||||
output = next(
|
||||
data
|
||||
for path, data in session.writes.items()
|
||||
if path.startswith("/tmp/surfsense-output-")
|
||||
)
|
||||
assert output.endswith(b"x")
|
||||
|
||||
|
||||
async def test_execute_bash_remains_a_direct_command(monkeypatch):
|
||||
session = FakeSandboxSession(
|
||||
{}, command_handler=lambda command: ExecResult(command, 0)
|
||||
)
|
||||
|
||||
async def get_session(*_args):
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(sandbox_tools, "_get_session", get_session)
|
||||
tool = next(
|
||||
tool
|
||||
for tool in sandbox_tools.create_sandbox_tools(workspace_id=3)
|
||||
if tool.name == "execute"
|
||||
)
|
||||
|
||||
result = await tool.coroutine(
|
||||
code_or_command="printf done", language="bash", runtime=_runtime()
|
||||
)
|
||||
|
||||
assert session.commands == ["printf done"]
|
||||
assert result.startswith("printf done")
|
||||
|
||||
|
||||
async def test_load_artifact_instructions_uses_the_structured_format(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import pytest
|
|||
from app.agents.chat.multi_agent_chat.shared.middleware.filesystem.tools.execute_code import (
|
||||
helpers,
|
||||
)
|
||||
from app.config import config as app_config
|
||||
from app.sandbox import ExecResult, SandboxUnavailableError
|
||||
|
||||
|
||||
|
|
@ -52,6 +53,13 @@ def _install(monkeypatch, registry: FakeRegistry) -> None:
|
|||
monkeypatch.setattr(helpers, "get_registry", _get_registry)
|
||||
|
||||
|
||||
def test_execute_code_uses_the_shared_sandbox_operation_budget():
|
||||
assert (
|
||||
helpers.MAX_EXECUTE_TIMEOUT
|
||||
== app_config.SANDBOX_OPERATION_TIMEOUT_SECONDS
|
||||
)
|
||||
|
||||
|
||||
async def test_successful_run_reports_exit_code(monkeypatch, middleware):
|
||||
registry = FakeRegistry(FakeSession([ExecResult(output="42", exit_code=0)]))
|
||||
_install(monkeypatch, registry)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from opensandbox.exceptions import SandboxApiException
|
||||
|
||||
from app.sandbox.providers.opensandbox import OpenSandboxSession
|
||||
from app.config import config as app_config
|
||||
from app.sandbox.providers.opensandbox import OpenSandboxProvider, OpenSandboxSession
|
||||
|
||||
|
||||
class _Files:
|
||||
|
|
@ -19,6 +21,14 @@ def _session(exc: Exception) -> OpenSandboxSession:
|
|||
return OpenSandboxSession(sandbox, ttl_seconds=900)
|
||||
|
||||
|
||||
def test_provider_uses_the_shared_sandbox_operation_budget(monkeypatch) -> None:
|
||||
monkeypatch.setattr(app_config, "SANDBOX_OPERATION_TIMEOUT_SECONDS", 37)
|
||||
|
||||
provider = OpenSandboxProvider()
|
||||
|
||||
assert provider._config.request_timeout == timedelta(seconds=37)
|
||||
|
||||
|
||||
async def test_read_file_normalizes_provider_404() -> None:
|
||||
session = _session(
|
||||
SandboxApiException(
|
||||
|
|
|
|||
|
|
@ -130,9 +130,9 @@ export const TraceItemRow: FC<{
|
|||
"after:pointer-events-none after:absolute after:top-6 after:bottom-1 after:left-[7.5px]",
|
||||
"after:w-px after:bg-muted-foreground/20 last:after:hidden",
|
||||
status === "running" && "text-foreground",
|
||||
(status === "completed" || status === "reasoning") && "text-muted-foreground",
|
||||
(status === "completed" || status === "reasoning" || status === "error") &&
|
||||
"text-muted-foreground",
|
||||
status === "awaiting_approval" && "text-muted-foreground",
|
||||
status === "error" && "text-destructive",
|
||||
(status === "cancelled" || status === "interrupted") && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
145
surfsense_web/tests/unit/chat/artifact-publication.test.ts
Normal file
145
surfsense_web/tests/unit/chat/artifact-publication.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
artifactImageBlobQueryKey,
|
||||
artifactListQueryKey,
|
||||
artifactManifestQueryKey,
|
||||
invalidatePublishedArtifact,
|
||||
} from "@/features/artifacts/artifact-query";
|
||||
import {
|
||||
collectArtifacts,
|
||||
enrichArtifactRows,
|
||||
} from "@/features/chat-artifacts/lib/collect-artifacts";
|
||||
import { buildTurnRenderItems } from "@/features/chat-messages/timeline/grouping";
|
||||
|
||||
const bodyTools = new Set(["save_artifact"]);
|
||||
|
||||
test("only a successful save is rendered as a product body card", () => {
|
||||
for (const result of [
|
||||
undefined,
|
||||
{ status: "failed", artifact_id: 12 },
|
||||
{ status: "saved", artifact_id: null },
|
||||
]) {
|
||||
const [item] = buildTurnRenderItems({
|
||||
parts: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: "save_artifact",
|
||||
toolCallId: "save-1",
|
||||
result,
|
||||
},
|
||||
],
|
||||
bodyToolNames: bodyTools,
|
||||
showReasoning: true,
|
||||
threadRunning: false,
|
||||
});
|
||||
assert.equal(item?.kind, "segment");
|
||||
}
|
||||
|
||||
const [saved] = buildTurnRenderItems({
|
||||
parts: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: "save_artifact",
|
||||
toolCallId: "save-2",
|
||||
result: { status: "saved", artifact_id: 12 },
|
||||
},
|
||||
],
|
||||
bodyToolNames: bodyTools,
|
||||
showReasoning: true,
|
||||
threadRunning: false,
|
||||
});
|
||||
assert.equal(saved?.kind, "body-tool");
|
||||
});
|
||||
|
||||
test("revisions share one sidebar row with latest metadata and card target", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: "save_artifact",
|
||||
toolCallId: "old-card",
|
||||
args: { title: "Old title" },
|
||||
result: {
|
||||
status: "saved",
|
||||
artifact_id: 42,
|
||||
title: "Old title",
|
||||
files: [{ role: "primary", filename: "old.docx" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: "save_artifact",
|
||||
toolCallId: "failed-card",
|
||||
args: { title: "Broken revision" },
|
||||
result: { status: "failed", artifact_id: 42, error: "boom" },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: "save_artifact",
|
||||
toolCallId: "latest-card",
|
||||
args: { title: "New title" },
|
||||
result: {
|
||||
status: "saved",
|
||||
artifact_id: 42,
|
||||
title: "New title",
|
||||
files: [{ role: "primary", filename: "new.pdf" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as never;
|
||||
|
||||
const candidates = collectArtifacts(messages);
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.equal(candidates[0]?.toolCallId, "latest-card");
|
||||
|
||||
const [row] = enrichArtifactRows(candidates, [
|
||||
{
|
||||
artifact_id: 42,
|
||||
document_id: 99,
|
||||
title: "Canonical latest title",
|
||||
format: "pdf",
|
||||
generation: 2,
|
||||
indexing_status: "ready",
|
||||
created_at: "2026-08-18T00:00:00Z",
|
||||
updated_at: "2026-08-18T00:01:00Z",
|
||||
thread_id: 7,
|
||||
},
|
||||
]);
|
||||
assert.equal(row?.title, "Canonical latest title");
|
||||
assert.equal(row?.format, "pdf");
|
||||
assert.equal(row?.toolCallId, "latest-card");
|
||||
});
|
||||
|
||||
test("publication clears revision-sensitive caches and invalidates lists", async () => {
|
||||
const client = new QueryClient();
|
||||
const workspaceId = 3;
|
||||
const artifactId = 42;
|
||||
const manifestKey = artifactManifestQueryKey(workspaceId, artifactId);
|
||||
const listKey = artifactListQueryKey(workspaceId);
|
||||
const threadListKey = artifactListQueryKey(workspaceId, 7);
|
||||
const libraryKey = ["artifacts-library", workspaceId] as const;
|
||||
const imageKey = artifactImageBlobQueryKey(workspaceId, artifactId, null, 100);
|
||||
|
||||
for (const key of [manifestKey, listKey, threadListKey, libraryKey, imageKey]) {
|
||||
client.setQueryData(key, { cached: true });
|
||||
}
|
||||
|
||||
await invalidatePublishedArtifact(client, workspaceId, artifactId);
|
||||
|
||||
assert.equal(client.getQueryData(manifestKey), undefined);
|
||||
assert.equal(client.getQueryData(imageKey), undefined);
|
||||
assert.equal(client.getQueryState(listKey)?.isInvalidated, true);
|
||||
assert.equal(client.getQueryState(threadListKey)?.isInvalidated, true);
|
||||
assert.equal(client.getQueryState(libraryKey)?.isInvalidated, true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue