mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 21:42:22 +02:00
[eric] agents: RunToolScript (PTC): scripts chain allowlisted tools in one turn and only printed output enters context (hermes code_execution lift, ENG-354 lever)
This commit is contained in:
@@ -33,6 +33,7 @@ P_MODULE_FILES = {
|
||||
"web": "web_mcp_server",
|
||||
"browser": "browser_agent_mcp_server",
|
||||
"canvas": "canvas_mcp_server",
|
||||
"ptc": "ptc_mcp_server",
|
||||
}
|
||||
|
||||
P_ENABLED = [m.strip() for m in os.environ.get("OSW_MCP_MODULES", "meta,settings,apps").split(",") if m.strip()]
|
||||
|
||||
@@ -110,6 +110,12 @@ def build_effective_tool_lists(
|
||||
effective_allowed.append("mcp__openswarm-core__CanvasCommand")
|
||||
elif policy == "deny":
|
||||
effective_disallowed.append("mcp__openswarm-core__CanvasCommand")
|
||||
if "ptc" in p_modules:
|
||||
policy = builtin_perms.get("RunToolScript", "always_allow")
|
||||
if policy == "always_allow":
|
||||
effective_allowed.append("mcp__openswarm-core__RunToolScript")
|
||||
elif policy == "deny":
|
||||
effective_disallowed.append("mcp__openswarm-core__RunToolScript")
|
||||
if "web" in p_modules:
|
||||
# Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either.
|
||||
for wt in ("WebSearch", "WebFetch"):
|
||||
|
||||
@@ -75,6 +75,10 @@ def register_builtin_mcp_servers(
|
||||
# Schedule module: ScheduleWorkflow + CRUD + step editing so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists.
|
||||
modules.append("schedule")
|
||||
|
||||
# RunToolScript (PTC): script-chained tool calls whose intermediates never enter context; inner calls are allowlisted in the server itself.
|
||||
if builtin_perms.get("RunToolScript", "always_allow") != "deny":
|
||||
modules.append("ptc")
|
||||
|
||||
# Canvas control after spawn (ENG-334): move/collapse/tile/close/tidy; close is scoped server-side to the caller's own cards.
|
||||
if builtin_perms.get("CanvasCommand", "always_allow") != "deny":
|
||||
modules.append("canvas")
|
||||
|
||||
@@ -40,6 +40,7 @@ P_BLOCKING_TOOLS: Set[str] = {
|
||||
"CreateBrowserAgent", "BrowserAgent", "AppAgent",
|
||||
"SpawnAgent", "InvokeAgent", "RequestHumanIntervention",
|
||||
"MCPSearch", "MCPActivate",
|
||||
"RunToolScript",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RunToolScript: the model writes a short Python script that chains builtin tools, and only
|
||||
the script's printed output enters context (PTC, lifted from hermes-agent's code_execution_tool,
|
||||
MIT). A 10-page research sweep costs the tokens of its summary instead of ten raw page dumps.
|
||||
|
||||
Safety shape: the script subprocess gets a minimal env with NO auth token; every tool call is
|
||||
brokered here against an explicit ALLOWLIST (never the deny-list's guess), so the gated surfaces
|
||||
(MCP activation, delegation, HITL, canvas, schedules, SettingsWrite) are unreachable from scripts
|
||||
by construction. Agents already hold always_allow Bash, so this adds reach for no new privilege."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
SCRIPT_TIMEOUT_S = 300.0
|
||||
MAX_TOOL_CALLS = 50
|
||||
MAX_STDOUT_BYTES = 50_000
|
||||
|
||||
# Read/write-safe, ungated tools only. Everything else is invisible to scripts on purpose.
|
||||
SCRIPT_ALLOWED_TOOLS = ("WebSearch", "WebFetch", "MemoryRead", "MemoryWrite", "SettingsRead", "Skill")
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "RunToolScript",
|
||||
"description": (
|
||||
"Run a short Python script that chains multiple tool calls in ONE turn; only what "
|
||||
"the script print()s comes back to you, so bulky intermediate results never enter "
|
||||
"your context. Use this whenever a task needs 3+ tool calls whose raw outputs you "
|
||||
"would only aggregate anyway (fetch N pages and extract one fact each, search then "
|
||||
"fetch the top hits, sweep memory). The script gets one function: "
|
||||
"call_tool(name, args_dict) -> str, valid names: "
|
||||
+ ", ".join(SCRIPT_ALLOWED_TOOLS) + ". A failed tool raises PtcToolError (catchable). "
|
||||
"Print ONLY your distilled findings. Budget: "
|
||||
f"{MAX_TOOL_CALLS} tool calls, {SCRIPT_TIMEOUT_S:.0f}s, printed output capped at 50KB."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "Python source. Example: results = call_tool('WebSearch', {'query': 'x'}) then loop call_tool('WebFetch', {'url': u}) and print the aggregate.",
|
||||
},
|
||||
},
|
||||
"required": ["script"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def p_core():
|
||||
"""The running combined sidecar (spawned as __main__), which owns tool routing. Tests
|
||||
inject a stand-in via set_core_for_tests."""
|
||||
if p_core_override is not None:
|
||||
return p_core_override
|
||||
main_mod = sys.modules.get("__main__")
|
||||
if main_mod is not None and hasattr(main_mod, "P_ROUTE") and hasattr(main_mod, "p_call"):
|
||||
return main_mod
|
||||
return None
|
||||
|
||||
|
||||
p_core_override = None
|
||||
|
||||
|
||||
def set_core_for_tests(core) -> None:
|
||||
global p_core_override
|
||||
p_core_override = core
|
||||
|
||||
|
||||
def p_result_text(result: dict) -> str:
|
||||
parts = []
|
||||
for c in result.get("content", []) or []:
|
||||
if isinstance(c, dict) and c.get("type") == "text":
|
||||
parts.append(str(c.get("text", "")))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def p_dispatch(name: str, args: dict) -> dict:
|
||||
"""One brokered inner call: allowlist first, then the sidecar's own routing."""
|
||||
if name not in SCRIPT_ALLOWED_TOOLS:
|
||||
return {"text": f"tool '{name}' is not callable from scripts; allowed: {', '.join(SCRIPT_ALLOWED_TOOLS)}", "is_error": True}
|
||||
core = p_core()
|
||||
if core is None:
|
||||
return {"text": "tool routing unavailable", "is_error": True}
|
||||
mod = core.P_ROUTE.get(name)
|
||||
if mod is None:
|
||||
return {"text": f"tool '{name}' is not loaded in this session", "is_error": True}
|
||||
try:
|
||||
result = core.p_call(mod, name, args)
|
||||
except Exception as e:
|
||||
return {"text": f"tool '{name}' raised: {e}", "is_error": True}
|
||||
return {"text": p_result_text(result), "is_error": bool(result.get("isError"))}
|
||||
|
||||
|
||||
def p_elide(text: str, cap: int = MAX_STDOUT_BYTES) -> str:
|
||||
raw = text.encode("utf-8", errors="replace")
|
||||
if len(raw) <= cap:
|
||||
return text
|
||||
head = int(cap * 0.4)
|
||||
tail = cap - head
|
||||
return (
|
||||
raw[:head].decode("utf-8", errors="replace")
|
||||
+ f"\n\n[... output elided: {len(raw)} bytes total, cap {cap} ...]\n\n"
|
||||
+ raw[-tail:].decode("utf-8", errors="replace")
|
||||
)
|
||||
|
||||
|
||||
def p_runner_env() -> dict:
|
||||
# Minimal on purpose: the child needs no secrets because the parent brokers every call.
|
||||
keep = ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "SYSTEMROOT", "TEMP", "TMP")
|
||||
env = {k: os.environ[k] for k in keep if k in os.environ}
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def p_mcp_text(text: str, is_error: bool = False) -> dict:
|
||||
out = {"content": [{"type": "text", "text": text}]}
|
||||
if is_error:
|
||||
out["isError"] = True
|
||||
return out
|
||||
|
||||
|
||||
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
if tool_name != "RunToolScript":
|
||||
return p_mcp_text(f"Unknown tool: {tool_name}", is_error=True)
|
||||
script = str(arguments.get("script", "")).strip()
|
||||
if not script:
|
||||
return p_mcp_text("script is required", is_error=True)
|
||||
runner = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ptc_script_runner.py")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-u", runner],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
env=p_runner_env(), text=True,
|
||||
)
|
||||
except Exception as e:
|
||||
return p_mcp_text(f"could not start script runner: {e}", is_error=True)
|
||||
deadline = time.monotonic() + SCRIPT_TIMEOUT_S
|
||||
# The read loop blocks in readline, so the deadline needs teeth of its own: the timer kills the child, which turns the block into a clean EOF.
|
||||
p_reaper = threading.Timer(SCRIPT_TIMEOUT_S, lambda: proc.poll() is None and proc.kill())
|
||||
p_reaper.daemon = True
|
||||
p_reaper.start()
|
||||
calls_used = 0
|
||||
try:
|
||||
proc.stdin.write(json.dumps({"script": script}) + "\n")
|
||||
proc.stdin.flush()
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
proc.kill()
|
||||
return p_mcp_text(
|
||||
f"script exceeded the {SCRIPT_TIMEOUT_S:.0f}s budget after {calls_used} tool calls; nothing was returned. Break the work into smaller scripts.",
|
||||
is_error=True,
|
||||
)
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
if time.monotonic() > deadline:
|
||||
return p_mcp_text(
|
||||
f"script exceeded the {SCRIPT_TIMEOUT_S:.0f}s budget after {calls_used} tool calls; nothing was returned. Break the work into smaller scripts.",
|
||||
is_error=True,
|
||||
)
|
||||
return p_mcp_text(f"script runner exited unexpectedly after {calls_used} tool calls", is_error=True)
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if msg.get("done"):
|
||||
out = p_elide(str(msg.get("stdout", "")))
|
||||
err = msg.get("error")
|
||||
footer = f"\n\n[script ran {int(msg.get('calls', 0))} tool call(s)]"
|
||||
if err:
|
||||
return p_mcp_text(f"script raised {err}\n\npartial output:\n{out}{footer}", is_error=True)
|
||||
if not out.strip():
|
||||
return p_mcp_text(f"script printed nothing; print your findings next time.{footer}", is_error=True)
|
||||
return p_mcp_text(out + footer)
|
||||
call = msg.get("call")
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
calls_used += 1
|
||||
if calls_used > MAX_TOOL_CALLS:
|
||||
reply = {"seq": msg.get("seq"), "text": f"tool call budget ({MAX_TOOL_CALLS}) exhausted; print what you have", "is_error": True}
|
||||
else:
|
||||
reply = {"seq": msg.get("seq"), **p_dispatch(str(call.get("name", "")), dict(call.get("args") or {}))}
|
||||
proc.stdin.write(json.dumps(reply) + "\n")
|
||||
proc.stdin.flush()
|
||||
except BrokenPipeError:
|
||||
return p_mcp_text(f"script runner pipe broke after {calls_used} tool calls", is_error=True)
|
||||
finally:
|
||||
p_reaper.cancel()
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Subprocess half of RunToolScript (PTC, the hermes code_execution lift): executes ONE
|
||||
model-written Python script whose tool calls ride JSON lines back to the sidecar, so
|
||||
intermediate tool results never enter the model's context window; only what the script
|
||||
prints returns.
|
||||
|
||||
Protocol, all newline-delimited JSON over the real stdin/stdout:
|
||||
parent -> child {"script": "<python source>"}
|
||||
child -> parent {"call": {"name": str, "args": {}}, "seq": int}
|
||||
parent -> child {"seq": int, "text": str, "is_error": bool}
|
||||
child -> parent {"done": true, "stdout": str, "calls": int, "error": str|null}
|
||||
|
||||
The script's own print() goes to an in-memory buffer (the real stdout is the RPC channel),
|
||||
capped so a runaway loop can't balloon the process. Runs with a scrubbed env and no auth
|
||||
token: every tool call is brokered by the parent sidecar, which owns the allowlist."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
|
||||
P_STDOUT_CAP_BYTES = 5_000_000
|
||||
|
||||
p_rpc_out = sys.stdout
|
||||
p_rpc_in = sys.stdin
|
||||
p_seq = 0
|
||||
p_calls = 0
|
||||
|
||||
|
||||
class PtcToolError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def p_send(obj):
|
||||
p_rpc_out.write(json.dumps(obj) + "\n")
|
||||
p_rpc_out.flush()
|
||||
|
||||
|
||||
def p_recv():
|
||||
line = p_rpc_in.readline()
|
||||
if not line:
|
||||
raise PtcToolError("sidecar closed the pipe")
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
def call_tool(name, args=None):
|
||||
"""The one function scripts get: run a builtin tool, return its text result.
|
||||
|
||||
Raises PtcToolError when the tool itself errored, so a script can try/except
|
||||
around a flaky fetch instead of parsing error prose."""
|
||||
global p_seq, p_calls
|
||||
if not isinstance(name, str) or not name:
|
||||
raise PtcToolError("call_tool needs a tool name string")
|
||||
p_seq += 1
|
||||
p_calls += 1
|
||||
p_send({"call": {"name": name, "args": dict(args or {})}, "seq": p_seq})
|
||||
reply = p_recv()
|
||||
if reply.get("is_error"):
|
||||
raise PtcToolError(str(reply.get("text", "tool failed")))
|
||||
return str(reply.get("text", ""))
|
||||
|
||||
|
||||
class P_CappedBuffer(io.StringIO):
|
||||
def write(self, s):
|
||||
if self.tell() < P_STDOUT_CAP_BYTES:
|
||||
return super().write(s)
|
||||
return len(s)
|
||||
|
||||
|
||||
def main():
|
||||
first = p_recv()
|
||||
script = str(first.get("script", ""))
|
||||
buf = P_CappedBuffer()
|
||||
sys.stdout = buf
|
||||
error = None
|
||||
scope = {"call_tool": call_tool, "PtcToolError": PtcToolError, "__name__": "__ptc_script__"}
|
||||
try:
|
||||
exec(compile(script, "<tool_script>", "exec"), scope)
|
||||
except BaseException as e:
|
||||
error = f"{type(e).__name__}: {e}"
|
||||
finally:
|
||||
sys.stdout = p_rpc_out
|
||||
p_send({"done": True, "stdout": buf.getvalue(), "calls": p_calls, "error": error})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,6 +39,7 @@ BUILTIN_TOOLS: list[BuiltinTool] = [
|
||||
BuiltinTool(name="Agent", display_name="CreateAgent", description="Spawn a sub-agent to handle a complex subtask", category="agents"),
|
||||
BuiltinTool(name="InvokeAgent", description="Invoke a copy of an existing agent with a new message, preserving full conversation context", category="agents"),
|
||||
BuiltinTool(name="CanvasCommand", display_name="Canvas control", description="Move, collapse, tile, close, or tidy cards on the canvas after spawn", category="agents"),
|
||||
BuiltinTool(name="RunToolScript", display_name="Tool scripting", description="Chain many tool calls in one scripted step; only the distilled output enters the conversation", category="agents"),
|
||||
# These two always had real deny-gates at dispatch (register_builtin_mcp_servers), but were missing here, so the permissions API refused to store a policy for them and the gates were unreachable (ENG-284).
|
||||
BuiltinTool(name="ShowUI", display_name="Rich UI", description="Render rich inline components in chat, and ask questions through interactive UI", category="interaction"),
|
||||
BuiltinTool(name="Skill", description="Load an installed skill's instructions into the conversation", category="skills"),
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Pins the RunToolScript (PTC) contract: scripts chain allowlisted tools through the broker,
|
||||
only printed output returns, and every guardrail (allowlist, call cap, timeout, stdout cap,
|
||||
secret-free child env) actually bites. The whole point is intermediates never reach context,
|
||||
so the strongest assertion here is what the RESULT does NOT contain."""
|
||||
|
||||
import sys
|
||||
|
||||
from backend.apps.agents import ptc_mcp_server as ptc
|
||||
|
||||
|
||||
class P_FakeCore:
|
||||
"""Stands in for the combined sidecar: routes every allowlisted name to a canned handler."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.P_ROUTE = {name: self for name in ptc.SCRIPT_ALLOWED_TOOLS}
|
||||
|
||||
def p_call(self, mod, name, args):
|
||||
self.calls.append((name, args))
|
||||
if name == "WebFetch":
|
||||
return {"content": [{"type": "text", "text": "PAGE-BODY " + ("x" * 2000) + " NEEDLE:" + str(args.get("url"))}]}
|
||||
if name == "WebSearch":
|
||||
return {"content": [{"type": "text", "text": "r1 http://a\nr2 http://b"}]}
|
||||
if name == "MemoryWrite":
|
||||
return {"content": [{"type": "text", "text": "saved"}]}
|
||||
return {"content": [{"type": "text", "text": f"ok:{name}"}]}
|
||||
|
||||
|
||||
def p_run(script: str) -> dict:
|
||||
return ptc.handle_tool_call("RunToolScript", {"script": script})
|
||||
|
||||
|
||||
def p_text(result: dict) -> str:
|
||||
return result["content"][0]["text"]
|
||||
|
||||
|
||||
def setup_function(fn):
|
||||
ptc.set_core_for_tests(P_FakeCore())
|
||||
|
||||
|
||||
def teardown_function(fn):
|
||||
ptc.set_core_for_tests(None)
|
||||
|
||||
|
||||
def test_chained_calls_return_only_printed_output():
|
||||
core = P_FakeCore()
|
||||
ptc.set_core_for_tests(core)
|
||||
r = p_run(
|
||||
"urls = [u.split()[1] for u in call_tool('WebSearch', {'query': 'q'}).splitlines()]\n"
|
||||
"needles = [call_tool('WebFetch', {'url': u}).split('NEEDLE:')[1] for u in urls]\n"
|
||||
"print('needles: ' + ', '.join(needles))\n"
|
||||
)
|
||||
text = p_text(r)
|
||||
assert "needles: http://a, http://b" in text
|
||||
assert "PAGE-BODY" not in text, "intermediate tool output leaked into context"
|
||||
assert len(core.calls) == 3
|
||||
assert "[script ran 3 tool call(s)]" in text
|
||||
assert not r.get("isError")
|
||||
|
||||
|
||||
def test_non_allowlisted_tool_is_refused_but_catchable():
|
||||
r = p_run(
|
||||
"try:\n"
|
||||
" call_tool('MCPActivate', {'server_name': 'x'})\n"
|
||||
" print('ESCAPED')\n"
|
||||
"except PtcToolError as e:\n"
|
||||
" print('blocked: ' + str(e)[:40])\n"
|
||||
)
|
||||
text = p_text(r)
|
||||
assert "blocked:" in text
|
||||
assert "ESCAPED" not in text
|
||||
|
||||
|
||||
def test_call_cap_enforced():
|
||||
r = p_run(
|
||||
"hits = 0\n"
|
||||
"for i in range(60):\n"
|
||||
" try:\n"
|
||||
" call_tool('MemoryWrite', {'ops': []})\n"
|
||||
" hits += 1\n"
|
||||
" except PtcToolError:\n"
|
||||
" break\n"
|
||||
"print('completed ' + str(hits))\n"
|
||||
)
|
||||
assert f"completed {ptc.MAX_TOOL_CALLS}" in p_text(r)
|
||||
|
||||
|
||||
def test_script_exception_reports_partial_output():
|
||||
r = p_run("print('got this far')\nraise ValueError('boom')\n")
|
||||
text = p_text(r)
|
||||
assert r.get("isError") is True
|
||||
assert "ValueError: boom" in text
|
||||
assert "got this far" in text
|
||||
|
||||
|
||||
def test_empty_print_is_an_error_nudge():
|
||||
r = p_run("x = 1 + 1\n")
|
||||
assert r.get("isError") is True
|
||||
assert "printed nothing" in p_text(r)
|
||||
|
||||
|
||||
def test_stdout_capped_with_elide():
|
||||
r = p_run("print('A' * 200_000)")
|
||||
text = p_text(r)
|
||||
assert len(text.encode()) < ptc.MAX_STDOUT_BYTES + 500
|
||||
assert "output elided" in text
|
||||
|
||||
|
||||
def test_timeout_kills_hung_script(monkeypatch):
|
||||
monkeypatch.setattr(ptc, "SCRIPT_TIMEOUT_S", 3.0)
|
||||
r = p_run("import time\ntime.sleep(60)\nprint('never')\n")
|
||||
assert r.get("isError") is True
|
||||
assert "exceeded" in p_text(r)
|
||||
|
||||
|
||||
def test_child_env_carries_no_secrets(monkeypatch):
|
||||
monkeypatch.setenv("OPENSWARM_AUTH_TOKEN", "sekrit-token")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-nope")
|
||||
env = ptc.p_runner_env()
|
||||
joined = " ".join(f"{k}={v}" for k, v in env.items())
|
||||
assert "sekrit-token" not in joined
|
||||
assert "sk-ant-nope" not in joined
|
||||
assert "PATH" in env
|
||||
|
||||
|
||||
def test_negative_control_no_core_routing():
|
||||
ptc.set_core_for_tests(None)
|
||||
# No __main__ sidecar in pytest, so dispatch must fail closed, not crash.
|
||||
out = ptc.p_dispatch("WebFetch", {"url": "http://x"})
|
||||
assert out["is_error"] is True
|
||||
assert "unavailable" in out["text"]
|
||||
|
||||
|
||||
def test_runner_importable_and_single_purpose():
|
||||
import backend.apps.agents.ptc_script_runner as runner
|
||||
assert callable(runner.call_tool)
|
||||
assert sys.modules["backend.apps.agents.ptc_script_runner"] is runner
|
||||
Reference in New Issue
Block a user