Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"tools": [
|
||||
{
|
||||
"name": "echo",
|
||||
"description": "Echoes a message",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "The message to echo",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "executeBash",
|
||||
"description": "Execute a bash command in an isolated directory.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "The bash command to execute",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 1800,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Timeout in seconds (default 120, max 1800)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "executePython",
|
||||
"description": "Execute Python code in an isolated directory.\n\n``executePython`` was dropped from core; this shim reimplements it on core's\nsandbox, piping the source to ``python3 -`` via stdin \u2014 matching the original\nso large scripts aren't capped by the OS command-line length (``python3 -c\n<code>`` would put the whole program in argv and can hit \"Argument list too\nlong\").",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"code": {
|
||||
"description": "The Python code to execute",
|
||||
"type": "string"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maximum": 1800,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Timeout in seconds (default 120, max 1800)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "export_state",
|
||||
"description": "Export the full core state as JSON.\n\nRound-trips with import_state. State is keyed by snake_case entity-class\nname (e.g. ``project``, ``employee``, ``slack_message``).",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "import_state",
|
||||
"description": "Replace the full core state with the provided JSON.\n\nFor synthetic-data injection and test setup. Round-trips with export_state.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"state": {
|
||||
"additionalProperties": true,
|
||||
"description": "Empty state \u2014 core has no DB to snapshot.",
|
||||
"properties": {},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listFiles",
|
||||
"description": "List files and subdirectories in a directory inside the sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"directory": {
|
||||
"default": ".",
|
||||
"description": "Directory to list, relative to the sandbox root. Use /workdir/ prefix or an absolute path within the sandbox to be explicit. Defaults to the sandbox root.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prepareGradingContext",
|
||||
"description": "Prepare the text_for_grading string for rubric evaluation.\n\nCollects file evidence from the sandbox, formats as XML,\nand prepends to the agent's final_output.\n\nArgs:\n final_output: The agent's final response text.\n directory: Root directory to search. Defaults to the sandbox directory.\n extensions: Only include files with these extensions (without dot).\n exclude_patterns: Directory/file name patterns to skip.\n max_files: Maximum number of files to include as evidence. Eligible files\n beyond this cap are omitted but reported (logged and listed in the\n XML under <files_omitted_due_to_max_files>) rather than dropped\n silently. Defaults to DEFAULT_MAX_FILES.\n max_content_bytes: Maximum bytes of content to read per file.\n\nReturns:\n The assembled text_for_grading string.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"final_output": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"extensions": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"max_files": {
|
||||
"default": 100,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_content_bytes": {
|
||||
"default": 250000,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"final_output"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readFile",
|
||||
"description": "Read a file from the sandbox, with optional offset/limit (bytes) for paginating large files.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"description": "Path to the file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox.",
|
||||
"type": "string"
|
||||
},
|
||||
"offset": {
|
||||
"default": 0,
|
||||
"description": "Byte offset to start reading from. Use 0 for the beginning or a previous call's next_offset to continue.",
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": 200000,
|
||||
"description": "Maximum number of bytes to read. Defaults to 200000. Pass null to read from offset to end of file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readMedia",
|
||||
"description": "Read an image, PDF, audio, or video file and return it as multimodal MCP content (base64-encoded, capped at 20 MiB; images auto-resized to model limits, large PDFs read in page ranges).",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"description": "Path to an image (gif/jpeg/png/webp), PDF, audio (wav/mp3/aiff/aac/ogg/flac), or video (mp4/mpeg/mov/avi/flv/webm/wmv/3gpp) file. Use /workdir/ prefix for sandbox files, or an absolute path within the sandbox. Returns multimodal MCP content; images are automatically downscaled/re-encoded to fit model limits (max 2000px on the long edge). Audio/video require a model with native audio/video support (e.g. Gemini) \u2014 other models see only a text placeholder. Use readFile for text and readPDF for PDF text extraction.",
|
||||
"type": "string"
|
||||
},
|
||||
"pages": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "PDF page range to read, 1-indexed, e.g. '3' or '1-10' (max 20 pages per read). Required for PDFs over 20 pages or 10 MiB. Ignored for images."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readPDF",
|
||||
"description": "Extract text from a PDF file inside the sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "writeFile",
|
||||
"description": "Write content to a file in an isolated sandbox.",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"toolsets": {
|
||||
"read": [
|
||||
"echo",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF"
|
||||
],
|
||||
"read_multimodal": [
|
||||
"echo",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF"
|
||||
],
|
||||
"write": [
|
||||
"executeBash",
|
||||
"writeFile"
|
||||
],
|
||||
"debug": [
|
||||
"echo",
|
||||
"executeBash",
|
||||
"executePython"
|
||||
],
|
||||
"ds_all": [
|
||||
"executeBash",
|
||||
"executePython",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF",
|
||||
"writeFile"
|
||||
],
|
||||
"state": [
|
||||
"export_state",
|
||||
"import_state"
|
||||
],
|
||||
"grading": [
|
||||
"prepareGradingContext"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"run": {
|
||||
"command": "python",
|
||||
"args": ["-m", "syntara.server"],
|
||||
"env": {
|
||||
"SETUID": "1000",
|
||||
"SETGID": "1000",
|
||||
"HOME": "/home/model",
|
||||
"USER": "model",
|
||||
"LOGNAME": "model"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"command": "python",
|
||||
"args": ["-m", "syntara.setup"],
|
||||
"env": {
|
||||
"SETUID": "1000",
|
||||
"SETGID": "1000",
|
||||
"HOME": "/home/model",
|
||||
"USER": "model",
|
||||
"LOGNAME": "model"
|
||||
}
|
||||
},
|
||||
"namespaced": true,
|
||||
"toolsets": {
|
||||
"read": ["echo", "listFiles", "readFile", "readPDF"],
|
||||
"read_multimodal": [
|
||||
"echo",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF"
|
||||
],
|
||||
"write": ["executeBash", "writeFile"],
|
||||
"debug": ["echo", "executeBash", "executePython"],
|
||||
"ds_all": [
|
||||
"executeBash",
|
||||
"executePython",
|
||||
"listFiles",
|
||||
"readFile",
|
||||
"readPDF",
|
||||
"writeFile"
|
||||
],
|
||||
"state": ["export_state", "import_state"],
|
||||
"grading": ["prepareGradingContext"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
build-backend = "uv_build"
|
||||
requires = [ "uv-build>=0.11,<0.12" ]
|
||||
|
||||
[project]
|
||||
# TEMPORARY backward-compatibility shim. ``syntara`` was renamed to ``core``;
|
||||
# this package re-exposes the old ``syntara`` MCP surface (namespaced
|
||||
# ``syntara__*`` tools, legacy toolset names, ``executePython``) by forwarding
|
||||
# every call to ``core``. REMOVE this whole package after 2026-06-18 — see
|
||||
# packages/syntara/syntara/_compat.py for the removal checklist.
|
||||
name = "syntara"
|
||||
version = "0.0.1"
|
||||
description = "Temporary compatibility shim that forwards to the core MCP server"
|
||||
requires-python = ">=3.13"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"core",
|
||||
"fastmcp>=3.2,<4",
|
||||
"mcp[cli]>=1.19",
|
||||
"pydantic>=2",
|
||||
]
|
||||
scripts.syntara-mcp = "syntara.server:main"
|
||||
|
||||
[tool.uv]
|
||||
environments = [
|
||||
"sys_platform == 'darwin' and platform_machine == 'arm64'",
|
||||
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
||||
]
|
||||
build-backend.module-root = ""
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.pythonpath = [ "." ]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Shared bits for the temporary ``syntara``→``core`` compatibility shim.
|
||||
|
||||
``syntara`` was renamed to ``core`` (tools are now exposed un-namespaced as
|
||||
``bash``/``readFile``/… and ``executePython`` was dropped). This whole package
|
||||
exists only to keep pre-rename projects working while they migrate: it re-
|
||||
exposes the old ``syntara`` server (namespaced ``syntara__*`` tools, legacy
|
||||
toolset names, ``executePython``) by forwarding every call to ``core``.
|
||||
|
||||
Every forwarded call logs a deprecation warning (grep service logs for
|
||||
``DEPRECATED syntara compat``) so we can see which old features are still in use
|
||||
and chase down the last callers.
|
||||
|
||||
REMOVE AFTER 2026-06-18. Checklist:
|
||||
1. Delete the ``packages/syntara`` directory.
|
||||
2. Drop ``packages/syntara`` from the root ``pyproject.toml`` workspace.members.
|
||||
3. Remove the ``"syntara"`` entries from ``mcp_proxy/viewer.py`` name/icon maps.
|
||||
4. Remove the both-syntara-and-core guard in ``mcp_proxy.commands.mcp``
|
||||
(grep ``COMPAT_SHIM_NAME``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("syntara.compat")
|
||||
|
||||
REMOVAL_DATE = "2026-06-18"
|
||||
|
||||
|
||||
def _log_forward(tool: str) -> None:
|
||||
logger.warning(
|
||||
"DEPRECATED syntara compat: tool %r called (forwarding to core). seed=%s. The 'syntara' "
|
||||
"compatibility package is removed after %s — migrate to the 'core' tool/toolset names.",
|
||||
tool,
|
||||
os.environ.get("WORLDBENCH_SEED", "?"),
|
||||
REMOVAL_DATE,
|
||||
)
|
||||
|
||||
|
||||
def forwarding(name: str, core_fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wrap *core_fn* so each call logs a deprecation warning, then delegates.
|
||||
|
||||
``functools.wraps`` preserves the wrapped function's signature and docstring
|
||||
so the MCP tool schema introspected from this wrapper is identical to
|
||||
core's. The exposed tool name is still set explicitly at registration time.
|
||||
|
||||
The wrapper is a coroutine so the registered tool is async — core's tools are
|
||||
now coroutines, and FastMCP must run validation on the event loop rather than
|
||||
a worker threadpool (see ``async_tool_guard``).
|
||||
"""
|
||||
|
||||
@functools.wraps(core_fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
_log_forward(name)
|
||||
result = core_fn(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return result
|
||||
|
||||
wrapper.__name__ = name
|
||||
return wrapper
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Boot-time guard: every registered MCP tool must be async.
|
||||
|
||||
Sync (`def`) tools make FastMCP run pydantic argument validation in an anyio
|
||||
worker threadpool. Under concurrent calls the shared pydantic-core validator is
|
||||
re-entered across threads and panics with ``pyo3_runtime.PanicException:
|
||||
dictionary changed size during iteration``, which tears down the
|
||||
StreamableHTTP task group and turns every later request into a 500. Async
|
||||
(`async def`) tools validate on the single-threaded event loop and are safe.
|
||||
|
||||
This guard runs at server boot (and therefore during ``mcp-proxy gen``, which
|
||||
boots every server): a non-conformant package fails fast instead of shipping a
|
||||
server that can crash under load. The same check is intentionally duplicated in
|
||||
each package because the bundled prod images install only that package's own
|
||||
dependencies, so there is no shared runtime module to import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _unwrap(fn: Any) -> Any:
|
||||
while isinstance(fn, partial):
|
||||
fn = fn.func
|
||||
return fn
|
||||
|
||||
|
||||
def find_sync_tools(mcp: Any) -> list[str]:
|
||||
"""Return the names of registered tools whose function is not a coroutine."""
|
||||
# mcp.server.fastmcp.FastMCP exposes a synchronous tool manager.
|
||||
manager = getattr(mcp, "_tool_manager", None)
|
||||
if manager is not None and hasattr(manager, "list_tools"):
|
||||
return sorted(t.name for t in manager.list_tools() if not inspect.iscoroutinefunction(_unwrap(t.fn)))
|
||||
|
||||
# fastmcp.FastMCP exposes an async API.
|
||||
async def _collect() -> list[str]:
|
||||
sync: list[str] = []
|
||||
for descriptor in await mcp.list_tools():
|
||||
tool = await mcp.get_tool(descriptor.name)
|
||||
if not inspect.iscoroutinefunction(_unwrap(tool.fn)):
|
||||
sync.append(descriptor.name)
|
||||
return sorted(sync)
|
||||
|
||||
return asyncio.run(_collect())
|
||||
|
||||
|
||||
def assert_tools_async(mcp: Any) -> None:
|
||||
"""Raise if any registered tool is synchronous."""
|
||||
sync = find_sync_tools(mcp)
|
||||
if sync:
|
||||
raise RuntimeError(
|
||||
"MCP tools must be async (`async def`). Sync tools run pydantic argument "
|
||||
"validation in a worker threadpool and can trigger a pydantic-core "
|
||||
"'dictionary changed size during iteration' panic under concurrent calls, "
|
||||
"which kills the server. Make these tools async: " + ", ".join(sync)
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Legacy ``syntara`` MCP server — a compatibility shim over ``core``.
|
||||
|
||||
Builds a FastMCP app named ``syntara`` whose tools forward to ``core`` (see
|
||||
``syntara.tools``). Reuses core's privilege-drop, workdir, proxy-token, and
|
||||
viewer machinery verbatim so the runtime behaviour is identical to core; only
|
||||
the server name and the tool names/surface differ.
|
||||
|
||||
REMOVE with the rest of the ``syntara`` package after 2026-06-18 (see
|
||||
``syntara._compat``).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.tools.function_tool import FunctionTool
|
||||
|
||||
import syntara.tools as tools
|
||||
from core._token import capture_proxy_token
|
||||
from core.privilege import ensure_workdir
|
||||
from core.tools import sandbox
|
||||
from core.viewer import run_http_server
|
||||
from syntara.async_tool_guard import assert_tools_async
|
||||
|
||||
|
||||
def build_app() -> FastMCP:
|
||||
"""Build a FastMCP app exposing the legacy syntara tools (forwarding to core)."""
|
||||
app = FastMCP("syntara")
|
||||
for tool_name in sorted(tools.__all__):
|
||||
tool_fn = getattr(tools, tool_name, None)
|
||||
if callable(tool_fn):
|
||||
# output_schema=None matches core's server and the shipped
|
||||
# mcp-tools.generated.json, which never advertised output schemas.
|
||||
app.add_tool(FunctionTool.from_function(fn=tool_fn, name=tool_name, output_schema=None))
|
||||
return app
|
||||
|
||||
|
||||
@capture_proxy_token
|
||||
def main() -> None:
|
||||
# The server intentionally keeps running as root: it needs to read the
|
||||
# locked-down /app tree, and a root server lets us close /opt/venv to uid
|
||||
# 1000. Privilege is dropped per agent command instead — see
|
||||
# syntara.tools.sandbox._privilege_drop_kwargs (the chokepoint every
|
||||
# executeBash/executePython/file-tool subprocess flows through).
|
||||
ensure_workdir()
|
||||
# Land the running server in the agent's workdir so any tool that doesn't
|
||||
# pass cwd= explicitly resolves relative paths under /workdir. Skip when
|
||||
# the dir doesn't exist (CI / local dev where /workdir isn't provisioned).
|
||||
if os.path.isdir(sandbox.WORKDIR):
|
||||
os.chdir(sandbox.WORKDIR)
|
||||
app = build_app()
|
||||
assert_tools_async(app)
|
||||
|
||||
port = os.environ.get("PORT")
|
||||
if port:
|
||||
run_http_server(app, int(port))
|
||||
else:
|
||||
app.run(show_banner=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Legacy ``syntara`` setup hook — forwards to ``core.setup``.
|
||||
|
||||
The setup step (copying uploaded context files into the workdir) is identical
|
||||
for the shim, so we delegate to core's implementation and just log that the
|
||||
legacy entrypoint was used.
|
||||
|
||||
REMOVE with the rest of the ``syntara`` package after 2026-06-18 (see
|
||||
``syntara._compat``).
|
||||
"""
|
||||
|
||||
from core.setup import main as _core_setup_main
|
||||
from syntara._compat import _log_forward
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_log_forward("setup")
|
||||
_core_setup_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Legacy ``syntara`` tool surface, forwarding to ``core``.
|
||||
|
||||
Every tool here delegates to the corresponding ``core`` tool (logging a
|
||||
deprecation warning on each call). Two names differ from core:
|
||||
|
||||
* ``executeBash`` → core's renamed ``bash``.
|
||||
* ``executePython`` → removed from core; reimplemented here as
|
||||
``bash("python3 -c <code>")`` so pre-rename callers keep working.
|
||||
|
||||
REMOVE with the rest of the ``syntara`` package after 2026-06-18 (see
|
||||
``syntara._compat``).
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from core.tools import echo as _echo
|
||||
from core.tools import export_state as _export_state
|
||||
from core.tools import import_state as _import_state
|
||||
from core.tools import listFiles as _listFiles
|
||||
from core.tools import prepareGradingContext as _prepareGradingContext
|
||||
from core.tools import readFile as _readFile
|
||||
from core.tools import readMedia as _readMedia
|
||||
from core.tools import readPDF as _readPDF
|
||||
from core.tools import writeFile as _writeFile
|
||||
from core.tools.bash import bash as _bash
|
||||
from core.tools.sandbox import DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, run_in_sandbox
|
||||
from syntara._compat import _log_forward, forwarding
|
||||
|
||||
echo = forwarding("echo", _echo)
|
||||
listFiles = forwarding("listFiles", _listFiles)
|
||||
readFile = forwarding("readFile", _readFile)
|
||||
readMedia = forwarding("readMedia", _readMedia)
|
||||
readPDF = forwarding("readPDF", _readPDF)
|
||||
writeFile = forwarding("writeFile", _writeFile)
|
||||
prepareGradingContext = forwarding("prepareGradingContext", _prepareGradingContext)
|
||||
export_state = forwarding("export_state", _export_state)
|
||||
import_state = forwarding("import_state", _import_state)
|
||||
|
||||
# executeBash is just core's bash under its old name.
|
||||
executeBash = forwarding("executeBash", _bash)
|
||||
|
||||
|
||||
async def executePython(
|
||||
code: Annotated[str, Field(description="The Python code to execute")],
|
||||
timeout_seconds: Annotated[
|
||||
int | None,
|
||||
Field(
|
||||
description=f"Timeout in seconds (default {DEFAULT_TIMEOUT_SECONDS}, max {MAX_TIMEOUT_SECONDS})",
|
||||
ge=1,
|
||||
le=MAX_TIMEOUT_SECONDS,
|
||||
),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute Python code in an isolated directory.
|
||||
|
||||
``executePython`` was dropped from core; this shim reimplements it on core's
|
||||
sandbox, piping the source to ``python3 -`` via stdin — matching the original
|
||||
so large scripts aren't capped by the OS command-line length (``python3 -c
|
||||
<code>`` would put the whole program in argv and can hit "Argument list too
|
||||
long").
|
||||
"""
|
||||
_log_forward("executePython")
|
||||
timeout = timeout_seconds or DEFAULT_TIMEOUT_SECONDS
|
||||
return run_in_sandbox(["python3", "-"], timeout, input=code)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"echo",
|
||||
"executeBash",
|
||||
"executePython",
|
||||
"export_state",
|
||||
"import_state",
|
||||
"listFiles",
|
||||
"prepareGradingContext",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF",
|
||||
"writeFile",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""The syntara shim forwards every tool to core (with deprecation logging).
|
||||
|
||||
REMOVE with the rest of the syntara package after 2026-06-18.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import syntara.tools as tools
|
||||
from core.tools import sandbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workdir(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(sandbox, "WORKDIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_exposes_full_legacy_tool_surface():
|
||||
assert set(tools.__all__) == {
|
||||
"echo",
|
||||
"executeBash",
|
||||
"executePython",
|
||||
"export_state",
|
||||
"import_state",
|
||||
"listFiles",
|
||||
"prepareGradingContext",
|
||||
"readFile",
|
||||
"readMedia",
|
||||
"readPDF",
|
||||
"writeFile",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_forwards_to_core():
|
||||
# core.echo returns its message; the shim must return the same thing.
|
||||
from core.tools import echo as core_echo
|
||||
|
||||
assert await tools.echo("ping") == await core_echo("ping")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executebash_runs_a_command(workdir):
|
||||
result = await tools.executeBash("echo hello-from-shim")
|
||||
assert result["returncode"] == 0
|
||||
assert "hello-from-shim" in result["stdout"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executepython_runs_python(workdir):
|
||||
result = await tools.executePython("print(6 * 7)")
|
||||
assert result["returncode"] == 0
|
||||
assert result["stdout"].strip() == "42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executepython_handles_quotes(workdir):
|
||||
# Source goes over stdin, so embedded quotes need no escaping.
|
||||
result = await tools.executePython("print('a\"b\\'c')")
|
||||
assert result["returncode"] == 0
|
||||
assert result["stdout"].strip() == "a\"b'c"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executepython_handles_large_script_over_stdin(workdir):
|
||||
# The whole point of stdin delivery: a script far larger than the OS argv
|
||||
# limit (ARG_MAX ~256 KiB on macOS) must run. `python3 -c <code>` would fail
|
||||
# with "Argument list too long" before Python starts.
|
||||
big = "A" * 300_000
|
||||
result = await tools.executePython(f"print(len('{big}'))")
|
||||
assert result["returncode"] == 0, result
|
||||
assert result["stdout"].strip() == "300000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forwarded_call_logs_deprecation(workdir, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="syntara.compat"):
|
||||
await tools.executeBash("true")
|
||||
assert any("DEPRECATED syntara compat" in r.message and "executeBash" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executepython_logs_under_its_own_name(workdir, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="syntara.compat"):
|
||||
await tools.executePython("pass")
|
||||
assert any("executePython" in r.message for r in caplog.records)
|
||||
Reference in New Issue
Block a user