[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:
ciregenz
2026-08-18 23:41:38 -07:00
parent 8c2ebf0f21
commit e6d05dfb1b
8 changed files with 432 additions and 0 deletions
@@ -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",
}
+196
View File
@@ -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
+86
View File
@@ -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()
+1
View File
@@ -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"),
+137
View File
@@ -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