Add the pi harness: lightweight local-model agents with full gate parity

Model backend rows gain a harness column (claude | pi). A pi-harness row runs
the agent through the pi coding agent instead of the claude binary — pi speaks
the OpenAI Completions API natively, so a bare vLLM/llama.cpp/Ollama endpoint
needs no LiteLLM/claude-code-router translation proxy, and the loop is far
lighter for slow local token throughput. The Claude subscription and existing
claude-harness backends are untouched.

Parity comes from generated per-agent artifacts under ~/.handler-pi (outside
the repo tree, so the clean-tree gate never trips): models.json + settings.json
render the row as a pi provider pinned as the default model; a bundled bridge
extension (pi_bridge.ts) adapts pi's events to the exact stdin/stdout contract
of `python -m handler.hooks` — the Stop/completion gate re-prompts pi with
blockers via a follow-up message, git push runs the test/build/approval gates
and denies on failure, questions defer through an ask_operator tool into the
normal answer/resume flow, and memory recall is injected at session start. The
memory tools are registered natively (pi has no MCP), shelling to a new
`python -m handler.mcpserver --call <tool>` seam that reuses the MCP server's
implementations. Skills reuse the same ~/.claude/skills sync (pi implements the
same SKILL.md standard) plus the repo's committed .claude/skills.

Sessions are single JSONL files pre-assigned via --session, so cross-worker
resume archives/materializes exactly like claude's; the prompt travels on stdin
(pi has no -- separator). The supervisor normalizes pi's event stream on the
fly: assistant message_end feeds last_output, the final agent_end becomes the
run result. The whole chain was validated live against pi 0.84.1 with a stub
OpenAI endpoint: memory injection, push-gate denial (including the protected-
branch approval gate), stop-gate block loop, and ask_operator pause all ran
end to end through the real hooks and DB.

Also: harness selector in the dashboard Models form, pi baked into the control
image (NodeSource 22 for pi's node >= 22.19 floor), PI_BIN override, docs in
docs/local-models.md, fake_pi fixture + 12 tests (361 total green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdGv3u3DfTsP1S188KDhVH
This commit is contained in:
Claude
2026-08-12 18:21:35 +00:00
parent 356fa276a2
commit a3a5c272a2
24 changed files with 1417 additions and 84 deletions
+3 -2
View File
@@ -135,14 +135,15 @@ def fake_launch(monkeypatch):
calls: list[dict] = []
def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None):
def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None,
harness="claude"):
session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}"
with connection() as conn:
run = repo.create_run(conn, agent["id"], session_id, worker_id, kind)
repo.set_agent_session(conn, agent["id"], session_id, worker_id)
calls.append(
{"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path,
"env": env, "worker_id": worker_id, "run": run}
"env": env, "worker_id": worker_id, "run": run, "harness": harness}
)
return run
Vendored Executable
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""A stand-in ``pi`` binary for pi-harness headless-runner tests.
Wired in via the ``pi_bin`` setting (the same seam ``fake_claude.py`` uses for
``claude_bin``). Parses the real pi-harness argv (``-p --mode json --no-extensions
-e <bridge> --session <path>``), reads the prompt from **stdin** (pi has no ``--``
separator, so that is how the supervisor delivers it), emits a scripted ``--mode json``
event stream on stdout, and appends to the genuine ``--session`` transcript file so
archive/materialize/resume paths exercise the real single-file layout. Behavior is
selected with ``FAKE_PI_MODE``:
- ``success`` (default): session header + user/assistant message_end + agent_end +
agent_settled, exit 0.
- ``error``: header + one garbage line + agent_end whose assistant stopReason is
``error``, then exit 1 (pi's exit code for an errored final message).
- ``hang``: header, then sleep forever (kill/cancel tests SIGTERM it).
``FAKE_PI_EXPECT_HISTORY=1`` makes a run fail loudly (exit 3) when the ``--session``
file does not already exist — the cross-worker resume tests use it to prove the
archive really was materialized where pi would look.
"""
from __future__ import annotations
import json
import os
import signal
import sys
import time
from pathlib import Path
def _parse_argv(argv: list[str]) -> dict:
opts = {
"print": False,
"mode": None,
"no_extensions": False,
"extension": None,
"session": None,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "-p":
opts["print"] = True
elif arg == "--mode":
i += 1
opts["mode"] = argv[i]
elif arg == "--no-extensions":
opts["no_extensions"] = True
elif arg == "-e":
i += 1
opts["extension"] = argv[i]
elif arg == "--session":
i += 1
opts["session"] = argv[i]
i += 1
return opts
def _emit(event: dict) -> None:
sys.stdout.write(json.dumps(event) + "\n")
sys.stdout.flush()
def _assistant(text: str) -> dict:
return {
"role": "assistant",
"content": [{"type": "text", "text": text}],
"stopReason": "stop",
}
def main() -> int:
signal.signal(signal.SIGTERM, signal.SIG_DFL)
mode = os.environ.get("FAKE_PI_MODE", "success")
opts = _parse_argv(sys.argv[1:])
if not opts["print"] or opts["mode"] != "json" or not opts["session"]:
sys.stderr.write("fake_pi: expected -p --mode json --session <path>\n")
return 64
if not opts["no_extensions"] or not opts["extension"]:
sys.stderr.write("fake_pi: expected --no-extensions with an explicit -e bridge\n")
return 64
prompt = sys.stdin.read().strip()
session_path = Path(opts["session"])
is_resume = session_path.exists()
if os.environ.get("FAKE_PI_EXPECT_HISTORY") and not is_resume:
sys.stderr.write(f"fake_pi: session file missing at {session_path}\n")
return 3
_emit({"type": "session", "version": 3, "id": "internal-uuid", "cwd": os.getcwd()})
if mode == "hang":
time.sleep(3600)
return 0
user_msg = {"role": "user", "content": [{"type": "text", "text": prompt}]}
_emit({"type": "agent_start"})
_emit({"type": "message_end", "message": user_msg})
if mode == "error":
sys.stdout.write("this is not json\n")
sys.stdout.flush()
errored = {
"role": "assistant",
"content": [],
"stopReason": "error",
"errorMessage": "Connection error.",
}
_emit({"type": "agent_end", "messages": [user_msg, errored], "willRetry": False})
return 1
assistant = _assistant(f"working on: {prompt}")
_emit({"type": "message_end", "message": assistant})
session_path.parent.mkdir(parents=True, exist_ok=True)
with session_path.open("a") as fh:
fh.write(json.dumps({"type": "message", "message": user_msg}) + "\n")
fh.write(json.dumps({"type": "message", "message": assistant}) + "\n")
_emit({"type": "agent_end", "messages": [user_msg, assistant], "willRetry": False})
_emit({"type": "agent_settled"})
return 0
if __name__ == "__main__":
sys.exit(main())
+372
View File
@@ -0,0 +1,372 @@
"""The pi harness: config generation, the launch/resume paths against the fake ``pi``
binary, event normalization, and the API/spawn plumbing that selects it.
Same testing philosophy as the claude runner (``test_headless_run``): real
subprocesses, real threads, real SQLite — ``fake_pi.py`` stands in for the binary via
the ``pi_bin`` setting and emits genuine ``--mode json`` events, so what lands in the
DB is exactly what the API/UI will read. The bridge extension itself is TypeScript and
runs inside real pi, so here it is asserted as an artifact (installed, wired into
argv); its hook contract is the same ``python -m handler.hooks`` surface the hook tests
already cover.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
import pytest
from handler.control import headless, models, pi_harness, spawn
from handler.db import repository as repo
from handler.db.engine import get_engine
REPO_ROOT = Path(__file__).resolve().parents[1]
FAKE_PI = str(REPO_ROOT / "tests" / "fixtures" / "fake_pi.py")
@pytest.fixture
def pi_env(env, monkeypatch):
from handler import config
monkeypatch.setenv("PI_BIN", FAKE_PI)
config.get_settings.cache_clear()
yield env
config.get_settings.cache_clear()
def _pi_row(**overrides):
row = {
"name": "qwen-local",
"base_url": "http://127.0.0.1:8000/v1",
"model": "qwen3-coder-30b",
"small_fast_model": "qwen3-1.7b",
"harness": "pi",
"env": {},
}
row.update(overrides)
return row
def _make_agent(tmp_path, name="p1", model_id=None):
working_dir = tmp_path / "projects" / "p" / name
working_dir.mkdir(parents=True)
with get_engine().begin() as conn:
if repo.get_project(conn, "p") is None:
repo.create_project(conn, "p", str(tmp_path / "projects" / "p"))
agent = repo.create_agent(conn, "p", name, str(working_dir), model_id=model_id)
return agent
def _wait_for(predicate, timeout=20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(0.1)
return None
def _finished_run(run_id):
def check():
with get_engine().begin() as conn:
run = repo.get_run(conn, run_id)
return run if run["status"] != "running" else None
return check
# --- config generation -------------------------------------------------------------------
def test_write_config_renders_provider_and_bridge(pi_env, tmp_path):
wd = str(tmp_path / "wd")
base = pi_harness.write_config(wd, _pi_row(), "sk-local-123")
provider = json.loads((base / "models.json").read_text())["providers"]["handler"]
assert provider["baseUrl"] == "http://127.0.0.1:8000/v1"
assert provider["api"] == "openai-completions"
assert provider["apiKey"] == "sk-local-123"
assert [m["id"] for m in provider["models"]] == ["qwen3-coder-30b", "qwen3-1.7b"]
settings = json.loads((base / "settings.json").read_text())
assert settings["defaultProvider"] == "handler"
assert settings["defaultModel"] == "qwen3-coder-30b"
# Skills parity: the web-managed sync's user dir plus the repo's committed skills.
assert any(s.endswith(".claude/skills") for s in settings["skills"])
assert any(s.startswith(wd) for s in settings["skills"])
bridge = pi_harness.bridge_path(wd)
assert bridge.exists()
text = bridge.read_text()
# The bridge is the hooks adapter — it must shell to the hook dispatcher and the
# memory tool CLI, and register the question-deferral tool.
assert "handler.hooks" in text
assert "handler.mcpserver" in text
assert "ask_operator" in text
assert (base / "APPEND_SYSTEM.md").read_text().strip()
def test_write_config_row_env_tunes_provider(pi_env, tmp_path):
wd = str(tmp_path / "wd")
row = _pi_row(
env={
"PI_PROVIDER_API": "anthropic-messages",
"PI_CONTEXT_WINDOW": "32000",
"PI_MAX_TOKENS": "4096",
"SOME_VAR": "yes",
}
)
base = pi_harness.write_config(wd, row, "k")
provider = json.loads((base / "models.json").read_text())["providers"]["handler"]
assert provider["api"] == "anthropic-messages"
assert provider["models"][0]["contextWindow"] == 32000
assert provider["models"][0]["maxTokens"] == 4096
env = pi_harness.agent_env(wd, row)
# Config-only keys are consumed by the writer, not leaked into the process env;
# everything else passes through, and the defaults are present.
assert "PI_PROVIDER_API" not in env
assert env["SOME_VAR"] == "yes"
assert env["PI_OFFLINE"] == "1"
assert env["PI_CODING_AGENT_DIR"] == str(pi_harness.pi_dir(wd))
assert env["HANDLER_PYTHON"]
def test_build_argv_wires_bridge_and_session(pi_env, tmp_path):
wd = str(tmp_path / "wd")
argv = pi_harness.build_argv("sid-1", wd)
assert argv[1:5] == ["-p", "--mode", "json", "--no-extensions"]
assert argv[argv.index("-e") + 1] == str(pi_harness.bridge_path(wd))
assert argv[argv.index("--session") + 1] == str(pi_harness.session_file(wd, "sid-1"))
# No prompt in argv: pi has no ``--`` separator, so the task travels on stdin.
assert argv[-1] == str(pi_harness.session_file(wd, "sid-1"))
# --- model resolution --------------------------------------------------------------------
def test_resolve_model_and_harness(conn):
row = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
resolved = models.resolve_model(conn, row["id"])
assert models.harness_of(resolved) == "pi"
assert models.harness_of(None) == "claude"
# A pi row produces no ANTHROPIC_* env — its config is files, not env.
assert models.resolve_model_env(conn, row["id"]) == {}
# claude rows are unchanged.
claude_row = repo.create_claude_model(conn, "qwen-claude", "http://llm:4000", "qwen3")
env = models.resolve_model_env(conn, claude_row["id"])
assert env["ANTHROPIC_BASE_URL"] == "http://llm:4000"
# --- headless runs against the fake pi binary ---------------------------------------------
def test_pi_spawn_streams_events_and_completes(pi_env, tmp_path):
agent = _make_agent(tmp_path)
pi_harness.write_config(agent["working_dir"], _pi_row(), "k")
run = headless.launch(
agent, kind="spawn", prompt="build the thing",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
finished = _wait_for(_finished_run(run["id"]))
assert finished is not None, "run never finished"
assert finished["status"] == "completed"
assert finished["exit_code"] == 0
# agent_end normalized into the result the run row stores.
assert finished["result"]["is_error"] is False
assert finished["result"]["harness"] == "pi"
with get_engine().begin() as conn:
events = repo.list_agent_events(conn, agent["id"])
updated = repo.get_agent_by_id(conn, agent["id"])
archive = repo.get_session_archive(conn, agent["id"])
types = [e["type"] for e in events]
assert "session" in types and "agent_end" in types and "message_end" in types
# last_output comes from assistant message_end events (user ones don't count).
assert updated["last_output"] == "working on: build the thing"
assert updated["status"] == "blocked" # no hooks ran in the fake — not done
assert updated["session_id"] == run["session_id"]
# The single-file pi session was archived for cross-worker resume.
assert archive is not None
assert pi_harness.session_file(agent["working_dir"], run["session_id"]).exists()
def test_pi_failed_run_records_error_result(pi_env, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_PI_MODE", "error")
agent = _make_agent(tmp_path, "p-err")
run = headless.launch(
agent, kind="spawn", prompt="boom",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
finished = _wait_for(_finished_run(run["id"]))
assert finished["status"] == "failed"
assert finished["exit_code"] == 1
assert finished["result"]["is_error"] is True
with get_engine().begin() as conn:
events = repo.list_agent_events(conn, agent["id"])
assert repo.get_agent_by_id(conn, agent["id"])["status"] == "blocked"
raw = next(e for e in events if e["type"] == "raw")
assert "this is not json" in raw["payload"]["line"]
def test_pi_cancel_terminates_hanging_run(pi_env, tmp_path, monkeypatch):
monkeypatch.setenv("FAKE_PI_MODE", "hang")
agent = _make_agent(tmp_path, "p-hang")
run = headless.launch(
agent, kind="spawn", prompt="hang",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", harness="pi",
)
_wait_for(lambda: _events_count(agent["id"]) >= 1)
with get_engine().begin() as conn:
assert repo.request_run_cancel(conn, run["id"]) is True
finished = _wait_for(_finished_run(run["id"]), timeout=30.0)
assert finished is not None and finished["status"] == "canceled"
def _events_count(agent_id):
with get_engine().begin() as conn:
return len(repo.list_agent_events(conn, agent_id))
def test_pi_cross_worker_resume_materializes_single_file(pi_env, tmp_path, monkeypatch):
"""Worker B resumes a pi session it never ran, from the DB archive alone — the pi
analog of the claude linchpin test, on the single-file session layout."""
with get_engine().begin() as conn:
model = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
agent = _make_agent(tmp_path, "p-resume", model_id=model["id"])
pi_harness.write_config(agent["working_dir"], _pi_row(), "k")
run = headless.launch(
agent, kind="spawn", prompt="first pass",
settings_path=str(tmp_path / "s.json"), env={}, worker_id="worker-a", harness="pi",
)
assert _wait_for(_finished_run(run["id"]))["status"] == "completed"
# "Worker B": a clean HOME — no pi config, no session file.
other_home = tmp_path / "worker-b-home"
other_home.mkdir()
monkeypatch.setenv("HOME", str(other_home))
# The fake proves materialization: it exits 3 when the session file is absent.
monkeypatch.setenv("FAKE_PI_EXPECT_HISTORY", "1")
with get_engine().begin() as conn:
agent = repo.get_agent_by_id(conn, agent["id"])
ok, detail = spawn.resume(agent, "the operator's answer", worker_id="worker-b")
assert ok, detail
with get_engine().begin() as conn:
resumed = repo.get_latest_run(conn, agent["id"])
assert resumed["kind"] == "resume"
finished = _wait_for(_finished_run(resumed["id"]))
assert finished["status"] == "completed", f"exit={finished['exit_code']}"
assert finished["session_id"] == run["session_id"] # same session, continued
# Resume regenerated the pi config under worker B's HOME (row edits reach resumes).
assert pi_harness.pi_dir(agent["working_dir"]).exists()
# --- spawn plumbing ------------------------------------------------------------------------
def test_spawn_with_pi_model_launches_pi_harness(env, fake_launch, tmp_path):
root = tmp_path / "projects" / "proj"
root.mkdir(parents=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
with get_engine().begin() as conn:
repo.create_project(conn, "proj", str(root))
model = repo.create_claude_model(
conn, "qwen-pi", "http://127.0.0.1:8000/v1", "qwen3", harness="pi"
)
agent = spawn.spawn("proj", "worker", task="do it", model_id=model["id"])
call = fake_launch[0]
assert call["harness"] == "pi"
assert call["env"]["PI_CODING_AGENT_DIR"] == str(pi_harness.pi_dir(agent["working_dir"]))
assert "ANTHROPIC_BASE_URL" not in call["env"]
# The config artifacts were materialized before launch.
assert pi_harness.bridge_path(agent["working_dir"]).exists()
provider = json.loads(
(pi_harness.pi_dir(agent["working_dir"]) / "models.json").read_text()
)["providers"]["handler"]
assert provider["baseUrl"] == "http://127.0.0.1:8000/v1"
def test_spawn_with_claude_model_still_launches_claude(env, fake_launch, tmp_path):
root = tmp_path / "projects" / "proj2"
root.mkdir(parents=True)
(root / ".mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n")
with get_engine().begin() as conn:
repo.create_project(conn, "proj2", str(root))
model = repo.create_claude_model(conn, "qwen-claude", "http://llm:4000", "qwen3")
spawn.spawn("proj2", "worker", task="do it", model_id=model["id"])
call = fake_launch[0]
assert call["harness"] == "claude"
assert call["env"]["ANTHROPIC_BASE_URL"] == "http://llm:4000"
assert "PI_CODING_AGENT_DIR" not in call["env"]
# --- API -------------------------------------------------------------------------------------
def test_model_api_harness_round_trip(client, auth):
r = client.post(
"/claude/models",
json={
"name": "qwen-pi",
"base_url": "http://127.0.0.1:8000/v1",
"model": "qwen3",
"harness": "pi",
},
headers=auth,
)
assert r.status_code == 201
assert r.json()["harness"] == "pi"
# Default stays claude, and junk is rejected with a clean 422.
r = client.post(
"/claude/models",
json={"name": "plain", "base_url": "http://llm:4000", "model": "m"},
headers=auth,
)
assert r.json()["harness"] == "claude"
r = client.post(
"/claude/models",
json={"name": "bad", "base_url": "http://x", "model": "m", "harness": "aider"},
headers=auth,
)
assert r.status_code == 422
model_id = client.get("/claude/models", headers=auth).json()[0]["id"]
r = client.patch(f"/claude/models/{model_id}", json={"harness": "pi"}, headers=auth)
assert r.status_code == 200 and r.json()["harness"] == "pi"
# --- hook input: harness-provided final text --------------------------------------------------
def test_stop_checkpoint_prefers_harness_final_text(env, monkeypatch, tmp_path):
"""The pi bridge passes the closing message directly (pi session files aren't
claude-transcript-shaped); the checkpoint must prefer it over the transcript parse."""
from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
agent = _make_agent(tmp_path, "hooked")
ident = Identity(agent["id"], "p", "hooked", working_dir=agent["working_dir"])
hook_input = HookInput(
raw={"session_id": "s1", "final_assistant_text": "shipped the feature"},
event="stop",
)
with get_engine().begin() as conn:
result = checkpoint.handle_stop(conn, ident, hook_input)
cm = repo.get_checkmark(conn, agent["id"])
assert result == {}
assert cm["status"] == "done"
assert cm["where_it_stopped"] == "shipped the feature"