feat: code exec tool and desc

This commit is contained in:
Alex committed 2026-06-26 11:30:55 +01:00
1 parent 0606c5ddcb
commit b61e190df3
6 files changed
+217 -66

No files matched your search

+35 -44
View File
@@ -77,9 +77,11 @@ class CodeExecutorTool(Tool):
{
"name": "run_code",
"description": (
"Execute code in a sandboxed, stateful session bound to this conversation. "
"Execute Python in a sandboxed, stateful session bound to this conversation. "
"Files written by the code are captured as downloadable artifacts; only a "
"compact summary (output tail + artifact references) is returned, never raw bytes."
"compact summary (output tail + artifact references) is returned, never raw bytes. "
"Each call is capped at ~60s of wall-clock; for longer work, start it in the "
"background and poll with additional run_code calls (use persist=true to keep state)."
),
"active": True,
"require_approval": self._require_approval,
@@ -88,16 +90,8 @@ class CodeExecutorTool(Tool):
"properties": {
"code": {
"type": "string",
"description": "Source code to execute in the session.",
},
"language": {
"type": "string",
"description": "Programming language (default: python).",
},
"libraries": {
"type": "array",
"items": {"type": "string"},
"description": "Packages to ensure are installed before running.",
"description": "Python source to execute in the session. Install packages from "
"within the code itself (e.g. subprocess pip install) if needed.",
},
"inputs": {
"type": "array",
@@ -106,10 +100,6 @@ class CodeExecutorTool(Tool):
"ref like `A1` returned by a previous artifact action, a full artifact id, or "
"the name/id of a file the user attached to this conversation.",
},
"timeout": {
"type": "integer",
"description": "Wall-clock timeout in seconds for this execution.",
},
"ttl": {
"type": "integer",
"description": "Keep-alive lifetime (seconds) for the session; clamped by SANDBOX_MAX_TTL.",
@@ -133,21 +123,9 @@ class CodeExecutorTool(Tool):
]
def get_config_requirements(self) -> Dict[str, Any]:
"""Return configuration requirements (approval gate + backend selection)."""
return {
"require_approval": {
"type": "boolean",
"label": "Require approval",
"description": "Pause for human approval before each code execution.",
"required": False,
},
"sandbox_backend": {
"type": "string",
"label": "Sandbox backend",
"description": "Code-execution backend (defaults to the SANDBOX_BACKEND setting).",
"required": False,
},
}
"""Return configuration requirements (none; approval is an action-level flag,
and the sandbox backend is a deployment-level setting)."""
return {}
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
"""Return the primary produced artifact id so the UI artifact rail lights up."""
@@ -184,7 +162,7 @@ class CodeExecutorTool(Tool):
should_capture = kwargs.get("capture_artifacts", True)
ttl = self._coerce_int(kwargs.get("ttl"))
timeout = self._resolve_timeout(kwargs.get("timeout"))
timeout = self._exec_timeout()
inputs = kwargs.get("inputs") or []
manager = SandboxCreator.get_manager()
@@ -356,11 +334,21 @@ class CodeExecutorTool(Tool):
if stderr_tail:
payload["stderr_tail"] = stderr_tail
if not result.ok:
payload["error"] = (
f"{result.error_name}: {result.error_value}"
if result.error_name
else (result.error_value or "execution error")
)
if self._is_timeout(result):
cap = int(self._exec_timeout())
payload["error"] = (
f"Execution timed out. Each run_code call is capped at {cap}s and the limit "
"cannot be raised. For long-running work, start it in the background (e.g. launch a "
"subprocess or `nohup ... &` and write progress to a file) and return immediately, "
"then poll with additional run_code calls to check on it. Pass persist=true (or a "
"ttl) so the background process and its files survive between calls."
)
else:
payload["error"] = (
f"{result.error_name}: {result.error_value}"
if result.error_name
else (result.error_value or "execution error")
)
if inputs_loaded:
payload["inputs_loaded"] = inputs_loaded
return payload
@@ -387,13 +375,16 @@ class CodeExecutorTool(Tool):
return None
return parsed if parsed > 0 else None
def _resolve_timeout(self, requested: Any) -> float:
"""Return the stricter of the requested timeout and the sandbox's default cap."""
cap = float(getattr(settings, "SANDBOX_EXEC_TIMEOUT", 60))
parsed = self._coerce_int(requested)
if parsed is None:
return cap
return float(min(parsed, cap))
@staticmethod
def _exec_timeout() -> float:
"""Return the fixed per-run wall-clock cap (SANDBOX_EXEC_TIMEOUT; not caller-adjustable)."""
return float(getattr(settings, "SANDBOX_EXEC_TIMEOUT", 60))
@staticmethod
def _is_timeout(result: ExecResult) -> bool:
"""True when a failed exec looks like a wall-clock timeout (any backend's naming/message)."""
blob = f"{result.error_name or ''} {result.error_value or ''}".lower()
return "timeout" in blob or "timed out" in blob
@staticmethod
def _keep_alive(persist: Any, ttl: Optional[int]) -> bool:
+1 -1
View File
@@ -373,7 +373,7 @@ class Settings(BaseSettings):
DAYTONA_API_KEY: Optional[str] = None # Daytona Cloud API key (secret)
DAYTONA_API_URL: Optional[str] = None # override Daytona API base URL, if self-targeting
DAYTONA_TARGET: Optional[str] = None # Daytona region/target, e.g. "us"
DAYTONA_SNAPSHOT: Optional[str] = None # snapshot/image id to create sandboxes from
DAYTONA_SNAPSHOT: Optional[str] = None # image for new sandboxes; render libs via scripts/build_daytona_snapshot.py
DAYTONA_LANGUAGE: str = "python" # default runtime language for created sandboxes
DAYTONA_AUTO_STOP_INTERVAL: int = 15 # minutes idle before Daytona auto-stops a sandbox (0 disables)
DAYTONA_AUTO_DELETE_INTERVAL: int = 60 # minutes after stop before Daytona auto-deletes (-1 disables)
+25
View File
@@ -109,6 +109,31 @@ app chooses it). The same applies to k8s: `SANDBOX_KERNEL_NAME=docsgpt-python`
is set on the `docsgpt-api` and `docsgpt-worker` deployments in
`deployment/k8s/deployments/docsgpt-deploy.yaml`.
## Artifact rendering on Daytona (snapshot)
The `artifact` tool renders `presentation` / `document` / `spreadsheet` / `pdf`
specs by running a fixed renderer **inside the sandbox** that imports
`python-pptx`, `python-docx`, `openpyxl`, and `reportlab` (HTML/markdown need no
library). The self-hosted Jupyter runner inherits these from the backend venv,
but Daytona's default snapshot is a plain Python image — so under
`SANDBOX_BACKEND=daytona` those renders fail with `render failed: ExecutionError`
(a `ModuleNotFoundError` raised inside the sandbox). HTML/markdown still work.
Bake the libraries into a Daytona snapshot once, then point `DAYTONA_SNAPSHOT`
at it:
```bash
# Reads DAYTONA_API_KEY / DAYTONA_API_URL / DAYTONA_TARGET from .env:
python scripts/build_daytona_snapshot.py # builds "docsgpt-artifacts-py312"
# then in .env:
# DAYTONA_SNAPSHOT=docsgpt-artifacts-py312
```
The snapshot lives in **your** Daytona account, so each deployment builds its
own — the script is idempotent and skips if the name already exists. Keep the
pins in `scripts/build_daytona_snapshot.py` in sync with the backend venv so the
Daytona render output matches the Jupyter-backend output.
## Document reading (parsing worker — not the sandbox)
Document reading no longer runs in this sandbox. The `read_document` tool and the
@@ -131,6 +131,11 @@ const ConversationBubble = forwardRef<
const formatToolName = (toolName: string | undefined): string => {
if (!toolName) return '';
// Display-name overrides for tools whose label differs from the formatted key.
const overrides: Record<string, string> = {
artifact_generator: 'Artifact',
};
if (overrides[toolName]) return overrides[toolName];
return toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
@@ -691,9 +696,9 @@ const ConversationBubble = forwardRef<
</div>
)}
{message && (
<div className="my-2 ml-2 flex flex-wrap justify-start gap-y-2">
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
{type === 'ERROR' ? (
<div className="relative mr-2 block items-center justify-center">
<div className="relative block items-center justify-center">
<div>{retryBtn}</div>
</div>
) : (
@@ -702,7 +707,7 @@ const ConversationBubble = forwardRef<
completedArtifactCalls.map((artifactCall, artifactIndex) => (
<div
key={artifactCall.call_id ?? artifactIndex}
className="relative mr-2 flex items-center justify-center"
className="relative flex items-center justify-center"
>
<Button
type="button"
@@ -743,11 +748,11 @@ const ConversationBubble = forwardRef<
))}
{!isStreaming && (
<>
<div className="relative mr-2 block items-center justify-center">
<div className="relative block items-center justify-center">
<CopyButton textToCopy={message} />
</div>
{research && message && (
<div className="relative mr-2 block items-center justify-center">
<div className="relative block items-center justify-center">
<Button
type="button"
variant="ghost"
@@ -782,12 +787,12 @@ const ConversationBubble = forwardRef<
</Button>
</div>
)}
<div className="relative mr-2 block items-center justify-center">
<div className="relative block items-center justify-center">
<SpeakButton text={message} />
</div>
{handleFeedback && (
<>
<div className="relative mr-2 flex items-center justify-center">
<div className="relative flex items-center justify-center">
<Button
type="button"
variant="ghost"
@@ -810,7 +815,7 @@ const ConversationBubble = forwardRef<
</Button>
</div>
<div className="relative mr-2 flex items-center justify-center">
<div className="relative flex items-center justify-center">
<Button
type="button"
variant="ghost"
+108
View File
@@ -0,0 +1,108 @@
"""Build a Daytona snapshot preloaded with the artifact-render libraries.
The Daytona managed sandbox backend (``SANDBOX_BACKEND=daytona``) creates each
session from a snapshot. The default snapshot is a plain Python image, so the
``artifact`` tool's renderers — which ``import`` ``python-pptx`` / ``python-docx``
/ ``openpyxl`` / ``reportlab`` inside the sandbox — fail with
``render failed: ExecutionError``. This script bakes those libraries into a
snapshot once; point ``DAYTONA_SNAPSHOT`` at its name to fix rendering on Daytona.
Usage::
# Reads DAYTONA_API_KEY / DAYTONA_API_URL / DAYTONA_TARGET from .env (settings):
python scripts/build_daytona_snapshot.py
python scripts/build_daytona_snapshot.py --name docsgpt-artifacts-py312 --python 3.12
Then set in .env::
DAYTONA_SNAPSHOT=docsgpt-artifacts-py312
Keep the pins in sync with the backend venv (python-pptx / openpyxl / lxml /
pillow are in application/requirements.txt; python-docx and reportlab arrive
transitively) so the Daytona render output matches the Jupyter-backend output.
"""
from __future__ import annotations
import argparse
import sys
# Render libraries imported by the artifact tool's renderers, pinned to the
# versions installed in the backend venv as of this writing.
RENDER_PINS = [
"python-pptx==1.0.2",
"python-docx==1.2.0",
"openpyxl==3.1.5",
"reportlab==4.5.1",
"lxml==6.0.2",
"pillow==11.3.0",
]
DEFAULT_NAME = "docsgpt-artifacts-py312"
DEFAULT_PYTHON = "3.12"
def _parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse CLI arguments for the snapshot name and Python series."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--name", default=DEFAULT_NAME, help="snapshot name (default: %(default)s)")
parser.add_argument("--python", default=DEFAULT_PYTHON, help="Python series, e.g. 3.12")
parser.add_argument(
"--force", action="store_true", help="rebuild even if a snapshot with that name exists"
)
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
"""Build (or skip) the snapshot and print the value to set as DAYTONA_SNAPSHOT."""
args = _parse_args(argv)
from application.core.settings import settings
if not settings.DAYTONA_API_KEY:
print("DAYTONA_API_KEY is not set (check .env).", file=sys.stderr)
return 2
from daytona import (
CreateSnapshotParams,
Daytona,
DaytonaConfig,
DaytonaConflictError,
Image,
)
cfg: dict[str, object] = {"api_key": settings.DAYTONA_API_KEY}
if settings.DAYTONA_API_URL:
cfg["api_url"] = settings.DAYTONA_API_URL
if settings.DAYTONA_TARGET:
cfg["target"] = settings.DAYTONA_TARGET
client = Daytona(DaytonaConfig(**cfg))
if not args.force:
try:
existing = client.snapshot.get(args.name)
print(f"snapshot {args.name!r} already exists (state={getattr(existing, 'state', '?')}).")
print(f"set DAYTONA_SNAPSHOT={args.name}")
return 0
except Exception as exc: # noqa: BLE001 - "not found" is the happy path; build it
if type(exc).__name__ != "DaytonaNotFoundError" and "not found" not in str(exc).lower():
print(f"warning: get({args.name!r}) probe: {type(exc).__name__}: {exc}", file=sys.stderr)
image = Image.debian_slim(args.python).pip_install(RENDER_PINS)
print(f"building snapshot {args.name!r} (python {args.python}) with: {', '.join(RENDER_PINS)}")
print("--- build logs ---")
try:
snap = client.snapshot.create(
CreateSnapshotParams(name=args.name, image=image), on_logs=print, timeout=600
)
except DaytonaConflictError:
print(f"snapshot {args.name!r} already exists (conflict) — reuse it or pass --force a new name.")
return 0
print("--- created ---")
print(f"name={getattr(snap, 'name', args.name)} state={getattr(snap, 'state', '?')}")
print(f"\nNow set in .env:\n DAYTONA_SNAPSHOT={args.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+35 -13
View File
@@ -108,12 +108,12 @@ def test_shape_payload_ok_with_artifacts():
def test_shape_payload_error_carries_clean_message_no_hang():
tool = _tool()
result = ExecResult(
status="error", error_name="TimeoutError",
error_value="execution exceeded 1.0s", exit_code=-1,
status="error", error_name="RuntimeError",
error_value="kernel reset mid-run", exit_code=-1,
)
payload = tool._shape_payload(result, artifacts=[], inputs_loaded=[])
assert payload["status"] == "error"
assert payload["error"] == "TimeoutError: execution exceeded 1.0s"
assert payload["error"] == "RuntimeError: kernel reset mid-run"
assert payload["artifacts"] == []
@@ -148,16 +148,37 @@ def test_resolve_session_id_sanitizes_disallowed_chars():
assert re.fullmatch(r"[A-Za-z0-9_-]+", sid)
def test_resolve_timeout_picks_the_stricter_cap(monkeypatch):
def test_exec_timeout_is_a_fixed_uncapped_value(monkeypatch):
from application.core import settings as settings_module
# The per-run wall-clock cap is fixed from settings; callers cannot pass one.
monkeypatch.setattr(settings_module.settings, "SANDBOX_EXEC_TIMEOUT", 60, raising=False)
assert CodeExecutorTool._exec_timeout() == 60.0
monkeypatch.setattr(settings_module.settings, "SANDBOX_EXEC_TIMEOUT", 90, raising=False)
assert CodeExecutorTool._exec_timeout() == 90.0
# The action schema no longer advertises language/libraries/timeout.
props = _tool().get_actions_metadata()[0]["parameters"]["properties"]
assert "timeout" not in props and "language" not in props and "libraries" not in props
def test_is_timeout_detects_any_backend_naming():
assert CodeExecutorTool._is_timeout(ExecResult(error_name="TimeoutError", error_value="x"))
assert CodeExecutorTool._is_timeout(ExecResult(error_name="DaytonaTimeoutError"))
assert CodeExecutorTool._is_timeout(ExecResult(error_value="process timed out"))
assert not CodeExecutorTool._is_timeout(ExecResult(error_name="ValueError", error_value="boom"))
def test_timeout_result_guides_backgrounding(monkeypatch):
from application.core import settings as settings_module
monkeypatch.setattr(settings_module.settings, "SANDBOX_EXEC_TIMEOUT", 60, raising=False)
tool = _tool()
assert tool._resolve_timeout(10) == 10.0 # under the cap
assert tool._resolve_timeout(999) == 60.0 # clamped to cap
assert tool._resolve_timeout(None) == 60.0 # default
assert tool._resolve_timeout("bad") == 60.0 # invalid -> default
assert tool._resolve_timeout(-5) == 60.0 # non-positive -> default
timed_out = ExecResult(status="error", error_name="TimeoutError", error_value="execution exceeded 60s")
err = tool._shape_payload(timed_out, [], [])["error"]
assert "60s" in err and "background" in err.lower() and "poll" in err.lower()
# A non-timeout failure keeps the raw name/value.
crashed = ExecResult(status="error", error_name="ValueError", error_value="boom")
assert tool._shape_payload(crashed, [], [])["error"] == "ValueError: boom"
def test_coerce_int_and_keep_alive():
@@ -187,10 +208,11 @@ def test_run_code_metadata_reflects_require_approval():
assert ungated.preview_decision("other", {}) == (True, False)
def test_config_requirements_expose_approval_and_backend():
reqs = CodeExecutorTool({}, user_id="u").get_config_requirements()
assert "require_approval" in reqs
assert "sandbox_backend" in reqs
def test_config_requirements_is_empty():
# Approval is an action-level flag (see test_run_code_metadata_reflects_require_approval)
# and the sandbox backend is a deployment-level setting, so the tool advertises no
# user-configurable requirements.
assert CodeExecutorTool({}, user_id="u").get_config_requirements() == {}
def test_execute_action_rejects_unknown_action_and_missing_code():