mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
feat: code exec tool and desc
This commit is contained in:
1 parent
0606c5ddcb
commit
b61e190df3
6 files changed
+217
-66
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:]))
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user