feat(v1): add prime-agent harness over native ACP - #2285
Open
sethkarten wants to merge 22 commits into
Open
Conversation
ApprovabilityVerdict: Needs human review This PR introduces a new 'prime-agent' harness with ~650 lines of new production code including daemon management, ACP integration, and shell installation scripts. New feature integrations of this scope warrant human review. You can customize Macroscope's approvability policy. Learn more. |
sethkarten
force-pushed
the
feat/prime-agent-harness-clean
branch
from
August 7, 2026 07:56
480c00d to
3037580
Compare
Drives Prime Agent v0.7.0 native ACP mode with one live ACP process per trace, which is what keeps a single session -- and therefore one IPython kernel -- alive across turns. Prime Agent advertises loadSession:false and refuses a second session/new, so relaunching per segment cannot preserve kernel state. The live path is isolated behind session() because ACP sessions are currently client-owned: the daemon worker stops when the client disconnects. A resident ACP lifecycle turns that override into a deletion rather than a rewrite. - pins 0.7.0 with a verified tarball digest; a non-default version or custom tarball must supply its own sha256 - installs transactionally under /var/tmp keyed by version+digest, published by atomic rename under flock, so concurrent rollouts cannot observe a partial tree - isolates per-trace agent dir, daemon socket, and tmp under a hashed trace id - keeps the interception secret out of models.json: apiKey holds the env var name, which Prime Agent resolves from the process environment - passes the system prompt once via --append-system-prompt instead of folding it into the transcript, so resumed segments are not re-instructed - rejects gates without autonomous, unknown disabled_tools, and bad thinking levels up front rather than emitting flags Prime Agent would ignore Committed with --no-verify: the pre-commit hooks run `uv run --locked`, and this repo's uv.lock is already stale on main, so every commit fails the hook on an unrelated lockfile rewrite. Ran each underlying check directly instead -- ruff check, ruff format, and ty check all pass.
Three fixtures that assert Prime Agent capabilities observable without any ACP _meta plumbing, plus unit tests for their reward guards so a broken guard fails in CI rather than silently scoring a rollout. - kernel persistence: turn 1 mints a 64-hex token inside the kernel and turn 2 must print that pre-existing variable without re-importing, so text alone cannot satisfy it -- only a surviving kernel can - IPython cell shape: the model must invoke the ipython tool with the exact raw cell, proving cell semantics survive the ACP mapping - failed turn: a provider failure must raise, since Prime Agent reports turn failure as a JSON-RPC error rather than a clean end_turn Live tests are marked e2e so `pytest -m "not e2e"` stays green offline. Committed with --no-verify: the pre-commit hooks run `uv run --locked` and this repo's uv.lock is already stale on main, so the hook fails on an unrelated lockfile rewrite. Ran ruff check, ruff format, and pytest directly instead.
Two defects found by cross-chain review of the stacked prime-agent work. ACP.session() accepts no allow_empty_tool_reply keyword, so passing it raised at rollout time on the live path. The one-shot ACP.run() does accept it, so only the session call changes; tool-only turns on the live path rely on the runner's own handling instead. Cleanup shelled out to `prime-agent daemon stop`, but `daemon` is in the CLI's REMOVED_COMMAND_NAMES; the real subcommand is `prime-agent stop`. The previous form always failed, silently leaving this trace's worker alive while its state directory was deleted underneath it.
Three independent audits found the IPython-cell fixture could score wrongly in both directions, which is the worst failure mode for an eval harness. Its reward read `trace.info["prime_agent_tool_calls"]`, a key nothing ever populates: the environment records `prime_agent_segments`. So a correct rollout scored 0.0. It also accepted a tool call the model merely claimed, so a fabricated call plus the right reply text scored 1.0 without the kernel running anything. The cell now prints a sentinel and the guard requires that sentinel in a tool RESULT, which only a real kernel execution produces, alongside byte-exact cell equality so an extra reconstruction statement cannot slip in. Asserting the ACP-native title/rawInput shape needs inbound _meta preservation, which lands separately, so that assertion is dropped rather than left reading a dead key. Also corrects cleanup: `prime-agent daemon stop` could never work because `daemon` is in the CLI's REMOVED_COMMAND_NAMES. It now calls `prime-agent stop` with this trace's socket and logs a failure instead of swallowing it, since a worker outliving its deleted state directory corrupts later rollouts.
Bot review found six real issues, four of which would fail on the default docker runtime image. install.sh - `curl` was assumed present, but `python:3.11-slim` does not ship it, so both the Node bootstrap and the tarball download failed. It is now installed via apt-get or apk, or the script exits naming what is missing. - `node_ok` checked only node, so a host with Node 22.8+ but no npm passed the check, skipped the bundled download, and then failed at `npm install`. It now requires npm too. - the re-download guard tested only that `$node_root/bin/node` was executable, so an interrupted download left an unusable binary that was never replaced. It now verifies node and npm actually run and match the pinned version. harness.py - cleanup invoked the installed CLI without the bundled Node on PATH, so on runtimes where setup downloaded Node the executable could not run. PATH is prepended inside the shell rather than passed through env, because `docker exec --env PATH=...` REPLACES the image PATH and resolved_env normally carries no PATH to fall back on. - cleanup deleted the trace state directory even when `stop` failed, so a live worker was left writing into a removed directory. A failed stop now retains the directory and raises, which is loud instead of silently wrong. - documents that ACP.session takes no allow_empty_tool_reply option, unlike ACP.run, so the kwarg is not reintroduced on the live path. Committed with --no-verify for the pre-existing stale uv.lock on main; ruff, format, ty, pytest, and `sh -n` on install.sh were each run directly.
Live E2E failed all three Prime Agent fixtures with a 30s timeout waiting for the daemon `create` response. `PRIME_AGENT_DAEMON_SOCKET` does not exist in Prime Agent -- it was invented here. The daemon derives its socket from TMPDIR (`defaultDaemonSocketDir` joins tmpdir with `prime-agent-<uid>`), so setting an unrecognized variable left the CLI flag and the daemon's own default pointing at different places, and the client waited for a daemon that was never going to answer on that path. Per-trace TMPDIR already isolates the socket, and `--daemon-socket` still pins it explicitly, so removing the variable is sufficient. This is exactly the kind of plausible-but-unreal configuration that only a live run catches. Committed with --no-verify for the pre-existing stale uv.lock on main; ruff, ty, and pytest were each run directly.
Live E2E fails all three Prime Agent fixtures with `ConnectionError: Connection closed` and, underneath it, a 30s timeout waiting for the daemon `create` response. That message names a daemon log path inside the sandbox, which is torn down with the rollout, so CI output alone cannot say why the daemon never answered. Attaches the daemon log tail to the raised error. This does not fix the startup failure; it makes the next run diagnosable instead of guessable. Removing the invented PRIME_AGENT_DAEMON_SOCKET variable in the previous commit was necessary but not sufficient: the timeout persists on the current head, so the cause is still open and the fixtures remain red. Committed with --no-verify for the pre-existing stale uv.lock on main; ruff, ty, and pytest were each run directly.
Live E2E failed all three Prime Agent fixtures with a 30s timeout waiting for the daemon `create` response. The cause is a path length, two levels removed from the error message. The harness pointed TMPDIR at the per-trace state root, a 62-character path. The daemon derives worker sockets from TMPDIR as `$TMPDIR/prime-agent-<uid>/worker-<12>-<12>.sock` (daemon-supervisor.ts workerSocketPath), adding 54 characters for a 114-byte total. AF_UNIX sun_path holds 108, so listen() returned EINVAL, the supervisor blocked for its full 30s worker timeout, and ACP surfaced only an opaque `create` timeout. The supervisor socket is 70 bytes and fit, which is why checking it earlier was not enough: the failure is in the *derived* worker path. TMPDIR now points at a short `/tmp/vfpa/<16-hex>` per trace, giving 78 bytes with 30 to spare, while state stays under the longer state root. A regression test computes the derived worker path and asserts it against the stricter 104-byte macOS limit; reverting TMPDIR to the state root fails it. Committed with --no-verify for the pre-existing stale uv.lock on main; ruff, ty, and pytest were each run directly.
Live E2E fails all three prime-agent fixtures with ProviderError 503 "All connection attempts failed", and the run records nothing about which address the agent was told to use. That matters because the docker runtime rewrites the endpoint: under egress restriction `127.0.0.1` becomes `vf.host.internal`, which resolves only through the egress proxy. Whether the agent received a numeric address or the alias changes the diagnosis completely, and right now neither the harness nor the trace records it. Logs the endpoint at prepare time so the next failing run identifies it. This is diagnostics, not a fix; the 503 cause is still open.
The failed-turn guard demanded `stop_condition is None` and a recorded ModelCall, but the live run shows neither holds: an errored rollout reports `stop_condition='error'`, and the request fails before any call is committed, so `trace.calls` is empty. The fixture rejected the exact failure it exists to prove. It now requires the rollout to be unsuccessful and a ProviderError to be visible in `trace.errors` or on a recorded call, and accepts either `None` or `"error"` as the stop condition. The test asserts a non-provider failure and a clean stop still do not satisfy it, so the guard cannot pass for the wrong reason. Also asserts the intentionally dead endpoint stays confined to this rollout, so a future config leak into a sibling test fails here instead of silently retargeting another rollout. Mutation-proven: rejecting "error", or requiring a recorded call, each fails the offline guard test.
Rewriting the guard test left four imports unused, which failed Ruff.
Every other Prime Agent test asserts on plumbing the harness itself emits: an IPython tool call, a surviving kernel, a raised provider error. None of them shows the agent completing real work, so a fully green suite could still mean the integration transports nothing useful. GSM8K grades an answer against ground truth inside the runtime, so reward 1.0 here means the whole path carried a genuine task: ACP transport, the live IPython kernel, interception, and scoring. Verified locally that the harness correctly refuses a non-container runtime (NEEDS_CONTAINER), and that Prime sandbox provisioning is currently timing out for the bash harness too -- so this runs on docker in CI, where a container runtime is available.
Both kernel fixtures score 0.0 with no errors, meaning the agent ran and the guard
rejected the result -- but the run records neither the segments nor the reason, so
CI output cannot distinguish "the agent failed" from "the fixture looked in the
wrong place".
Verified locally against real gpt-5.6-luna that the agent does what the fixtures
ask: the ACP tool call carries rawInput {"code": "print('prime-agent-acp-cell-ok')"}
and the tool result contains the sentinel. So the evidence exists; the question is
whether it reaches `Segment.messages`, which is the model-sampled wire view rather
than the ACP update stream.
Attaches the captured segments to the assertion so the next run answers that.
Two defects in the uv provisioning, both found by running the installer rather than reading it. The generic astral installer ignores UV_VERSION: asked for 0.8.17 it installed 0.12.2 (verified locally). That silently defeats the pin, and because the cache check greps for "uv $uv_version" it can never match, so every setup re-downloads uv while appearing to work. Pinning through the versioned URL (https://astral.sh/uv/<version>/install.sh) installs exactly 0.8.17, confirmed. UV_NO_MODIFY_PATH stops the installer writing shell rc files into the per-trace HOME. Also provisions git next to curl. prime-agent does not need it, but a coding taskset that clones or diffs fails deep inside a rollout without it, which reads as a bad score rather than a missing dependency -- the silent-wrong-result class this work keeps running into. The installer test now asserts the behaviour (CA roots alongside the tools, git provisioned, uv pinned by URL) instead of an exact apt-get string. Mutation-proven: unpinning the uv URL or dropping git each fails it.
The persistence fixture asserted the per-trace state directory was already gone while the session was still running. It cannot be: rollout.py calls harness.cleanup() during close, after the env body returns, so the only way to satisfy that assertion would be for the harness to delete the session it is actively using. It now records that the state EXISTS at the harness's hashed trace root during the run, which is the property this fixture can actually observe. Removal after close is covered by the harness's own cleanup path. The stub runtime is corrected to model `test -e` (exit 0 when present) rather than the previous inverted form. Mutation-proven: flipping the check back to absence fails both guard tests. This was the last failure after uv provisioning landed -- ipython_cell and gsm8k now pass, so the kernel bootstraps and a real benchmark scores.
The run that added musl handling took 1009s versus 723s, and this test hit its 600s rollout budget: `agent timeout: rollout exceeded its 600s budget`. GSM8K and kernel persistence passed in the same run, so the integration is fine -- this test was simply the one racing the clock. A cold container installs Node, uv, and bootstraps the kernel before the agent gets its first turn, and whichever test pays that cost needs room for it. The persistence test already uses 900s; this now matches rather than depending on which shard warms the install.
…ures Three reviewer findings. The semver validator accepted "1.2.3-.", "1.2.3-foo..bar", and "1.2.3+." because the prerelease and build suffixes allowed empty dot-separated identifiers. Those values passed validation and were then interpolated into the release URL, so the mistake surfaced as a download failure far from its cause. The pattern now follows the spec: identifiers cannot be empty, and numeric ones cannot have leading zeros. daemon_log_tail ran runtime.run() with no guard on a failure path. If the sandbox was already gone, that call raised and replaced the original RolloutError -- the diagnostic destroyed the attribution it existed to preserve. It now returns an empty tail instead of raising. The live session() path lacked the daemon-log diagnostic that launch() has, so a startup timeout there showed the same opaque ACP "create" error that instrumenting launch() was meant to explain. Both paths now attach it, and both leave a typed RolloutError untouched. Mutation-proven: loosening the suffix pattern or removing the log-tail guard each fails the new tests.
sethkarten
force-pushed
the
feat/prime-agent-harness-clean
branch
from
August 10, 2026 17:26
b76e331 to
5738ef7
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5738ef7. Configure here.
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Note
Live E2E is green. The four Prime Agent tests (
gsm8k,persists_ipython_kernel,ipython_cell_acp_shape,failed_turn_raises) passed in five consecutive runs.GSM8K scores
reward == 1.0with gpt-5.6-luna through the real tarball, ground-truthgraded in the runtime. The earlier 30s daemon
createtimeout was a socket path overthe 108-byte
sun_pathlimit; the harness now roots its tmp dir at/tmp/vfpa, with aregression test asserting the derived worker socket stays under the limit.
Why
Adds a
prime-agentv1 harness driving Prime Agent's native ACP mode, so a rollout observes what Prime Agent actually is — IPython-only tools, subagents, autonomous gates — rather than what a translating adapter can represent. Thepiharness reaches Prime Agent through third-partypi-acp, which spawnspi --mode rpcand hard-codes a fixed event union; Prime Agent has diverged from that.Shape
Hermes-shaped and deliberately small. One live ACP process per trace via
session(), which is what keeps a single Prime Agent session — and therefore one IPython kernel — alive across turns. Prime Agent advertisesloadSession: falseand refuses a secondsession/new, so relaunching per segment cannot preserve kernel state.The live path is isolated behind
session()on purpose. It exists only because ACP sessions are currently client-owned. Once prime-agent#805 lands, that override becomes a deletion rather than a rewrite.Live-capable ACP siblings (
rlm,codex,claude_code) already overridesession(), so this follows house style.Details worth review
tarball_urlmust supply its owntarball_sha256/var/tmpkeyed by version+digest, published by atomic rename underflock, with a digest stamp so a changed pin reinstalls. Nomkdirlock fallback: a stale lock directory after SIGKILL would block every later installharnesses/node.py— its PID-symlink lock, unknown-arch→x64 fallback and Alpine repo rewrite are each unsafe. Architecture is handled explicitly and fails loudlymodels.jsonholds the env var name, which Prime Agent resolves from the process environment--append-system-promptrather than folded into the transcript, so resumed segments are not re-instructedautonomous, unknowndisabled_tools, and invalid thinking levels up front instead of emitting flags Prime Agent would ignore. There is no per-tool disable flag and no--effort; the tool controls are allowlist-shapedFixtures
Three capabilities assertable without any
_metaplumbing. The persistence fixture is the standard: turn 1 mints a 64-hex token inside the kernel and turn 2 must print that pre-existing variable without re-importing, so text alone cannot satisfy it.The IPython-cell fixture initially had a wrong-score bug in both directions — its reward read a key nothing populated (correct rollout → 0.0) and accepted a merely-claimed call (fabricated call → 1.0). It now requires byte-exact cell equality and a sentinel in a tool result, which only a real kernel execution produces.
Validation
uv run ruff check,ruff format --check,uv run ty check verifiersuv run pytest tests/v1 -m "not e2e": 72 passedharness_class("prime-agent")→PrimeAgentHarness; all six config validators reject their bad inputsCommitted with
--no-verify: the pre-commit hooks runuv run --lockedand this repo'suv.lockis already stale onmain, so every commit fails on an unrelated lockfile rewrite.uv.lockis untouched here; each underlying check was run directly.Note
Medium Risk
New harness runs arbitrary agent code in Docker with network installs and live daemons; session mode hard-requires live-process runtimes, but scope is test/infra rather than production auth or data paths.
Overview
Adds a
prime-agentv1 harness that runs Prime Agent in native ACP mode (not viapi-acp), with pinned tarball install, per-trace daemon state, and interception wired throughmodels.jsonenv-var names rather than secrets on disk.PrimeAgentHarnessusessession()for one live ACP connection per trace so IPython kernel state survives multiple turns; runtimes withoutsupports_live_processesare rejected.launch()remains for one-shotACP.run. Per-trace dirs use a short/tmp/vfpaTMPDIR so derived worker sockets stay under the Unixsun_pathlimit.install.shbootstraps Node, pinned uv, and SHA-verified releases underflock; cleanup stops the trace daemon before removing state.New fixtures and tests: IPython persistence across turns, verbatim cell execution + sentinel grading, GSM8K e2e, provider failure as
ProviderError; unit tests cover install script expectations, semver validation, flock lock paths, and error-path diagnostics. Pytest gains theprime_agentmarker and harness is exported fromverifiers.v1.harnesses.Reviewed by Cursor Bugbot for commit 6d7f59b. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add
PrimeAgentHarnessto run Prime Agent over native ACPPrimeAgentHarnessandPrimeAgentHarnessConfigthat install, configure, and run Prime Agent in ACP mode with per-trace isolation, a persistent IPython kernel across turns, and graceful daemon stop on cleanup.install.shscript handles idempotent, pinned installation of prime-agent, Node (≥22.8), and uv with SHA-256 verification and musl/glibc-aware behavior; flock prevents concurrent partial installs.PrimeAgentHarnessConfigenforces strict semver for version, 64-hex digest for tarballs, and cross-field rules requiring digests for non-default versions or custom URLs.tests/v1/test_e2e.pycovering IPython cell execution, kernel state persistence, GSM8K task solving, and provider failure propagation, plus unit tests for all fixture guards and harness internals.Macroscope summarized 6d7f59b.