mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6zrBsUCNzpMBnov3rTVYV
253 lines
12 KiB
Python
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
|