Files
openswarm/backend/apps/agents/ptc_mcp_server.py
T

253 lines
12 KiB
Python

#!/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
# How many of a batch's calls may be in flight at once. The sidecar already runs a thread per MCP
# call, so this is not new concurrency; the cap is here so a 25-call fan-out cannot crowd out the
# chat's other builtin tools while it runs. Module-global, so two scripts in one sidecar share it
# rather than each helping themselves to a full width.
SCRIPT_FANOUT_WIDTH = 8
P_FANOUT_SLOTS = threading.Semaphore(SCRIPT_FANOUT_WIDTH)
# 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). Valid tool names: "
+ ", ".join(SCRIPT_ALLOWED_TOOLS) + ". Two functions:\n"
"call_tools(calls) -> list of results IN THE SAME ORDER, run concurrently. Use this "
"whenever the calls do not depend on each other, which is most sweeps. Each call is "
"{'name': ..., 'args': {...}}; each result has .ok, .text and .error, and one failure "
"never kills the batch. Example: "
"for r in call_tools([{'name': 'WebFetch', 'args': {'url': u}} for u in urls]): "
"print(r.text if r.ok else 'skipped ' + r.error)\n"
"call_tool(name, args_dict) -> str, one call, for a CHAIN where each call needs the "
"previous result. A failed tool raises PtcToolError (catchable).\n"
"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: hits = call_tool('WebSearch', {'query': 'x'}) (a chain step), then call_tools([{'name': 'WebFetch', 'args': {'url': u}} for u in urls]) to fetch them all at once, 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_dispatch_batch(calls: list, deadline: float) -> list:
"""Fan a batch out and gather in CALL ORDER. One call's failure is that call's result, never the
batch's, which is the whole reason a script would use this instead of a loop."""
p_out: list = [None] * len(calls)
def p_one(i: int, call: dict) -> None:
# Bounded acquire, never `with`. A slot held by a call that never returns (a wedged sidecar)
# would be lost to the PROCESS, and after eight of those every later batch in every script
# would block forever with nothing saying so. An honest per-item error beats a silent stall.
if not P_FANOUT_SLOTS.acquire(timeout=max(0.0, deadline - time.monotonic())):
p_out[i] = {"text": "no fan-out slot free inside the script budget", "is_error": True}
return
try:
p_out[i] = p_dispatch(str(call.get("name", "")), dict(call.get("args") or {}))
finally:
P_FANOUT_SLOTS.release()
p_threads = [threading.Thread(target=p_one, args=(i, c), daemon=True) for i, c in enumerate(calls)]
for t in p_threads:
t.start()
for t in p_threads:
# The script budget is the only ceiling; joining past it would let a batch outlive the
# deadline the reaper is already enforcing on the child.
t.join(timeout=max(0.0, deadline - time.monotonic()))
return [
r if r is not None else {"text": "call did not finish inside the script budget", "is_error": True}
for r in p_out
]
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)
batch = msg.get("calls")
if isinstance(batch, list):
p_budget = f"tool call budget ({MAX_TOOL_CALLS}) exhausted; print what you have"
# Per item, not per batch: the calls that fit still run, and the rest say why not.
p_room = max(0, MAX_TOOL_CALLS - calls_used)
p_run, p_over = batch[:p_room], batch[p_room:]
calls_used += len(batch)
p_results = p_dispatch_batch(p_run, deadline) if p_run else []
p_results += [{"text": p_budget, "is_error": True} for _ in p_over]
proc.stdin.write(json.dumps({"seq": msg.get("seq"), "results": p_results}) + "\n")
proc.stdin.flush()
continue
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