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:
@@ -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()))
|
||||
Reference in New Issue
Block a user