mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
The default surface was 34 tools, 61 KB, ~15,300 tokens of schema in every
prompt of every agent. The mode system already names what a session should
carry, but only the tools register_all_tools enumerates obeyed it: zsh,
curl, playwright and the helpers (ps, npx, uvx, jq, wget, cdp, open) stayed
on in every mode. A mode now decides every discovered tool, the `hanzo`
mode lists what a coding agent calls every few steps (18 tools, 32 KB,
~8,000 tokens), and the rest is a name away: HANZO_MCP_ENABLED_TOOLS=a,b
or =all, `tool enable`, or `mode`.
hanzo-tools-shell 0.6.6: run_shell kept every byte a foreground command
wrote until the command ended. `yes` for thirty seconds is gigabytes. It
keeps the first 16 MiB per stream and drains the rest.
The tests' session-wide patch of asyncio.create_subprocess_exec handed
every shell command a stream that never reached EOF; the first test to run
one read it in a loop until the machine was out of memory (22 GB, killed).
The patch is now opt-in and its process has exited.
bench/latency.py measures the cold start, the tools/list bill and the common
calls through a real MCP client over stdio. Interleaved rounds, wall and CPU
seconds, p50 over 6 cold starts:
cold wall cold CPU tools bytes ~tokens
installed 0.15.18 9.34 s 10.46 s 30 52,601 13,150
main 0.15.20 1.52 s 1.13 s 34 61,287 15,321
this 0.96 s 1.06 s 18 32,222 8,055
The installed environment's 9 s is its hanzoai 8.5.156, whose package init
imports the whole generated cloud client; hanzo-mcp >= 0.15.20 requires
hanzoai >= 8.5.624, which loads it lazily. Per-call latency is spawn-bound
(zsh 17.6 ms, git status 33 ms, fs.read 2.5 ms at p50) and moves with the
machine's load; no call path changed.
116 lines
5.6 KiB
Python
116 lines
5.6 KiB
Python
"""What an agent pays for hanzo-mcp, measured through a real MCP client over stdio.
|
|
|
|
python bench/latency.py --out bench/latency-after.json [--runs 5] [--calls 30]
|
|
|
|
Cold start: spawn -> initialize -> tools/list, wall seconds and the server's CPU seconds (wall moves with the
|
|
machine's load, CPU does not). The tools/list payload, in bytes and estimated tokens (4 bytes a token), is
|
|
what every prompt of an agent carries. Per call: p50 / p95 milliseconds of the common tools after a warm-up.
|
|
The load average is recorded beside every number."""
|
|
import argparse, asyncio, json, os, resource, statistics, sys, tempfile, time
|
|
|
|
from mcp import ClientSession, StdioServerParameters
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
PAGE = """<!doctype html><title>bench</title><h1>bench</h1>
|
|
<button id=go onclick="document.getElementById('o').textContent='clicked'">Go</button>
|
|
<input id=q aria-label=query><p id=o>idle</p>"""
|
|
|
|
|
|
def pct(xs, q):
|
|
xs = sorted(xs)
|
|
return xs[min(len(xs) - 1, int(round(q * (len(xs) - 1))))]
|
|
|
|
|
|
def params(root):
|
|
cwd = ARGS.cwd
|
|
return StdioServerParameters(command=sys.executable, args=["-m", "hanzo_mcp", "--transport", "stdio", "--allow-path", "/home/z"],
|
|
env={**os.environ, "HANZO_MCP_TRANSPORT": "stdio"}, cwd=cwd)
|
|
|
|
|
|
async def cold(root):
|
|
r0 = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
t0 = time.perf_counter()
|
|
async with stdio_client(params(root)) as (r, w):
|
|
async with ClientSession(r, w) as s:
|
|
await s.initialize()
|
|
t1 = time.perf_counter()
|
|
tools = (await s.list_tools()).tools
|
|
t2 = time.perf_counter()
|
|
r1 = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
blob = json.dumps([t.model_dump(exclude_none=True) for t in tools], separators=(",", ":"))
|
|
return {"initialize_s": t1 - t0, "list_s": t2 - t1, "ready_s": t2 - t0, "cpu_s": (r1.ru_utime + r1.ru_stime) - (r0.ru_utime + r0.ru_stime),
|
|
"tools": len(tools), "bytes": len(blob), "tokens_est": len(blob) // 4, "load": os.getloadavg()[0],
|
|
"per_tool_bytes": {t.name: len(json.dumps(t.model_dump(exclude_none=True), separators=(",", ":"))) for t in tools}}
|
|
|
|
|
|
async def call(s, name, args):
|
|
t = time.perf_counter()
|
|
res = await s.call_tool(name, args)
|
|
ms = (time.perf_counter() - t) * 1000.0
|
|
text = "".join(getattr(c, "text", "") for c in res.content)
|
|
return ms, bool(res.isError), text
|
|
|
|
|
|
async def warm(root, repo, calls):
|
|
page = os.path.join(root, "page.html")
|
|
with open(page, "w") as f:
|
|
f.write(PAGE)
|
|
big = os.path.join(repo, "pkg", "hanzo-mcp", "hanzo_mcp", "server.py")
|
|
cases = [
|
|
("fs.read", "fs", {"action": "read", "uri": big}),
|
|
("fs.search_text", "fs", {"action": "search_text", "pattern": "def main", "uri": os.path.join(repo, "pkg", "hanzo-mcp", "hanzo_mcp")}),
|
|
("zsh.echo", "zsh", {"command": "echo hi"}),
|
|
("git.status", "git", {"action": "status", "cwd": repo}),
|
|
("playwright.snapshot", "playwright", {"action": "snapshot"}),
|
|
("playwright.click", "playwright", {"action": "click", "selector": "#go"}),
|
|
("playwright.fill", "playwright", {"action": "fill", "selector": "#q", "text": "hello"}),
|
|
]
|
|
out = {}
|
|
async with stdio_client(params(root)) as (r, w):
|
|
async with ClientSession(r, w) as s:
|
|
await s.initialize()
|
|
await s.list_tools()
|
|
nav = await call(s, "playwright", {"action": "navigate", "url": "file://" + page})
|
|
out["playwright.navigate_first"] = {"ms": nav[0], "error": nav[1]}
|
|
for label, tool, args in cases:
|
|
n = calls if not label.startswith("playwright") else max(10, calls // 2)
|
|
for _ in range(3):
|
|
await call(s, tool, args)
|
|
ms, errs, last = [], 0, ""
|
|
for _ in range(n):
|
|
m, e, last = await call(s, tool, args)
|
|
ms.append(m)
|
|
errs += e
|
|
out[label] = {"p50_ms": statistics.median(ms), "p95_ms": pct(ms, 0.95), "n": n, "errors": errs, "load": os.getloadavg()[0], "sample": last[:80]}
|
|
return out
|
|
|
|
|
|
ARGS = None
|
|
|
|
|
|
async def main():
|
|
global ARGS
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--runs", type=int, default=5)
|
|
ap.add_argument("--calls", type=int, default=30)
|
|
ap.add_argument("--cwd", help="where the server starts: `python -m` imports hanzo_mcp from here first")
|
|
ap.add_argument("--repo", default=os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")))
|
|
a = ARGS = ap.parse_args()
|
|
with tempfile.TemporaryDirectory(dir=os.path.expanduser("~/.cache")) as root:
|
|
runs = [await cold(root) for _ in range(a.runs)]
|
|
calls = await warm(root, a.repo, a.calls)
|
|
ready = [r["ready_s"] for r in runs]
|
|
summary = {"cold": {"ready_s_p50": statistics.median(ready), "ready_s_max": max(ready), "cpu_s_p50": statistics.median(r["cpu_s"] for r in runs),
|
|
"tools": runs[0]["tools"], "bytes": runs[0]["bytes"], "tokens_est": runs[0]["tokens_est"], "runs": runs},
|
|
"calls": calls, "python": sys.version.split()[0]}
|
|
with open(a.out, "w") as f:
|
|
json.dump(summary, f, indent=1)
|
|
c = summary["cold"]
|
|
print("cold ready p50 %.2fs max %.2fs, cpu p50 %.2fs; tools/list %d tools %d bytes ~%d tokens" % (c["ready_s_p50"], c["ready_s_max"], c["cpu_s_p50"], c["tools"], c["bytes"], c["tokens_est"]))
|
|
for k, v in calls.items():
|
|
print("%-26s %s" % (k, ("p50 %.1f p95 %.1f ms (n=%d, errors %d) %s" % (v["p50_ms"], v["p95_ms"], v["n"], v["errors"], v["sample"])) if "p50_ms" in v else v))
|
|
|
|
|
|
asyncio.run(main())
|