feat(shared): long-lived Pi RPC client extracted from the memo-inbox gateway

Generalises PiRPC from pi-workspaces/memo-inbox/telegram-gateway/gateway.py,
which has run this pattern in production since 2026-07, and closes the four gaps
both existing scenarios shared:

- explicit minimal env, so provider and backend API keys never reach the node
  process (verified: 6 variables, an injected secret is withheld)
- start_new_session plus killpg on stop, so a stuck node tree cannot outlive the
  turn (verified: no orphan after stop)
- the loading-isolation flags are part of the launch contract instead of
  something each caller has to remember
- a per-turn deadline enforced with RPC abort rather than by killing the process

Retains the original's proven mechanics: strict newline-only JSONL framing,
correlation by id, agent_settled as terminal event, and receipts harvested from
tool_execution_end rather than from model prose.

PiLaunchConfig warns when skills are configured but no 'read' tool can be
active, which is exactly the condition that silently disabled Curator's SKILL.md.

Includes a zero-token smoke test: it drives a real pi process with get_state
only, so no model call is billed.
This commit is contained in:
Kai
2026-08-26 22:55:21 -07:00
parent 98635022d0
commit 65d2f5988b
2 changed files with 749 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
"""Zero-token smoke test for pi_rpc against a real pi process.
Sends only `get_state`, so no model call is made. Verifies that the launch
contract produces a live process, that the isolation flags land, and that the
process group is cleaned up.
Run: python3 shared/lib/py/tests/test_pi_rpc_smoke.py
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from pi_rpc import PiLaunchConfig, PiRpcClient # noqa: E402
REPO = Path(__file__).resolve().parents[4]
HARNESS = REPO / "docs" / "evidence" / "probe-harness"
PI_BIN = os.environ.get("PI_BIN", "/home/claw/.npm-global/bin/pi")
def check(label: str, ok: bool, detail: str = "") -> bool:
print(f" {'PASS' if ok else 'FAIL'} {label}{('' + detail) if detail else ''}")
return ok
async def main() -> int:
failures = 0
print("== build_args reflects the isolation baseline ==")
cfg = PiLaunchConfig(
pi_bin=PI_BIN,
workspace=HARNESS,
session_dir=Path("/tmp/pi-rpc-smoke-sessions"),
provider="zenmux",
model="openai/gpt-5.6-luna",
thinking="low",
display_name="pi_rpc smoke",
extensions=(HARNESS / "probe-ext.ts",),
skills=(HARNESS / ".pi" / "skills" / "probe-skill",),
session_id_prefix="", # ephemeral for the smoke test
receipt_tools=frozenset({"probe_plain_schema"}),
turn_deadline_seconds=45.0,
)
args = cfg.build_args(None)
for flag in ("--no-builtin-tools", "--no-extensions", "--no-skills",
"--no-prompt-templates", "--no-themes", "--approve", "--no-session"):
failures += not check(f"{flag} present", flag in args)
failures += not check("--tools absent (registry allowlist would block dynamic tools)",
"--tools" not in args)
print("== env is minimal ==")
os.environ["SMOKE_FAKE_SECRET"] = "must-not-propagate"
env = cfg.build_env()
failures += not check("secret withheld", "SMOKE_FAKE_SECRET" not in env)
failures += not check("PATH forwarded", "PATH" in env)
failures += not check(f"small env ({len(env)} vars)", len(env) < 20)
print("== live process: start, get_state, stop ==")
client = PiRpcClient(cfg)
try:
await client.start()
pid = client.pid
failures += not check("process running", client.running, f"pid={pid}")
state = await client.get_state()
failures += not check("get_state returned a model", bool(state.get("model")),
str(state.get("model", {}).get("id", "?")))
failures += not check("not streaming", state.get("isStreaming") is False)
failures += not check("thinkingLevel honoured", state.get("thinkingLevel") == "low",
str(state.get("thinkingLevel")))
stats = await client.get_session_stats()
failures += not check("get_session_stats works", "tokens" in stats)
finally:
await client.stop()
failures += not check("process stopped", not client.running)
if pid:
alive = subprocess.run(["ps", "-p", str(pid)], capture_output=True).returncode == 0
failures += not check("no orphan process", not alive)
print()
print("RESULT:", "all checks passed" if failures == 0 else f"{failures} check(s) failed")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))