Skip to content

feat(v1): add prime-agent harness over native ACP - #2285

Open
sethkarten wants to merge 22 commits into
mainfrom
feat/prime-agent-harness-clean
Open

feat(v1): add prime-agent harness over native ACP#2285
sethkarten wants to merge 22 commits into
mainfrom
feat/prime-agent-harness-clean

Conversation

@sethkarten

@sethkarten sethkarten commented Aug 7, 2026

Copy link
Copy Markdown

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.0 with gpt-5.6-luna through the real tarball, ground-truth
graded in the runtime. The earlier 30s daemon create timeout was a socket path over
the 108-byte sun_path limit; the harness now roots its tmp dir at /tmp/vfpa, with a
regression test asserting the derived worker socket stays under the limit.

Why

Adds a prime-agent v1 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. The pi harness reaches Prime Agent through third-party pi-acp, which spawns pi --mode rpc and 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 advertises loadSession: false and refuses a second session/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 override session(), so this follows house style.

Details worth review

  • pins 0.7.0 with a digest verified against the published tarball; a non-default version or custom tarball_url must supply its own tarball_sha256
  • installs transactionally under /var/tmp keyed by version+digest, published by atomic rename under flock, with a digest stamp so a changed pin reinstalls. No mkdir lock fallback: a stale lock directory after SIGKILL would block every later install
  • does not reuse harnesses/node.py — its PID-symlink lock, unknown-arch→x64 fallback and Alpine repo rewrite are each unsafe. Architecture is handled explicitly and fails loudly
  • per-trace agent dir, daemon socket and tmp under a hashed trace id, 0700/0600, cleanup scoped to that trace only
  • the interception secret never reaches disk: models.json holds the env var name, which Prime Agent resolves from the process environment
  • system prompt passed once via --append-system-prompt rather than folded into the transcript, so resumed segments are not re-instructed
  • rejects gates without autonomous, unknown disabled_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-shaped

Fixtures

Three capabilities assertable without any _meta plumbing. 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 verifiers
  • uv run pytest tests/v1 -m "not e2e": 72 passed
  • resolves through the loader: harness_class("prime-agent")PrimeAgentHarness; all six config validators reject their bad inputs
  • tarball digest verified by downloading the published 0.7.0 artifact
  • mutation: weakening the IPython guard to accept an unexecuted call fails; restoring passes

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 on an unrelated lockfile rewrite. uv.lock is 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-agent v1 harness that runs Prime Agent in native ACP mode (not via pi-acp), with pinned tarball install, per-trace daemon state, and interception wired through models.json env-var names rather than secrets on disk.

PrimeAgentHarness uses session() for one live ACP connection per trace so IPython kernel state survives multiple turns; runtimes without supports_live_processes are rejected. launch() remains for one-shot ACP.run. Per-trace dirs use a short /tmp/vfpa TMPDIR so derived worker sockets stay under the Unix sun_path limit. install.sh bootstraps Node, pinned uv, and SHA-verified releases under flock; 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 the prime_agent marker and harness is exported from verifiers.v1.harnesses.

Reviewed by Cursor Bugbot for commit 6d7f59b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add PrimeAgentHarness to run Prime Agent over native ACP

  • Introduces PrimeAgentHarness and PrimeAgentHarnessConfig that 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.
  • An install.sh script 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.
  • PrimeAgentHarnessConfig enforces strict semver for version, 64-hex digest for tarballs, and cross-field rules requiring digests for non-default versions or custom URLs.
  • Adds four e2e tests in tests/v1/test_e2e.py covering IPython cell execution, kernel state persistence, GSM8K task solving, and provider failure propagation, plus unit tests for all fixture guards and harness internals.
  • Risk: cleanup removes per-trace state and tmpdir only when daemon stop succeeds; failed stops leave artifacts on disk and re-raise the error.

Macroscope summarized 6d7f59b.

Comment thread verifiers/v1/harnesses/prime_agent/install.sh
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/install.sh Outdated
Comment thread verifiers/v1/harnesses/prime_agent/install.sh
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread tests/v1/fixtures/prime_agent_persistence_v1.py Outdated
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
@macroscopeapp

macroscopeapp Bot commented Aug 7, 2026

Copy link
Copy Markdown

Approvability

Verdict: 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.

Comment thread verifiers/v1/harnesses/prime_agent/harness.py Outdated
Comment thread verifiers/v1/harnesses/prime_agent/install.sh Outdated
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/install.sh
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread tests/v1/fixtures/prime_agent_persistence_v1.py
@sethkarten
sethkarten force-pushed the feat/prime-agent-harness-clean branch from 480c00d to 3037580 Compare August 7, 2026 07:56
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
Comment thread verifiers/v1/harnesses/prime_agent/harness.py
sethkarten and others added 13 commits August 10, 2026 10:17
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.
sethkarten and others added 8 commits August 10, 2026 10:17
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
sethkarten force-pushed the feat/prime-agent-harness-clean branch from b76e331 to 5738ef7 Compare August 10, 2026 17:26

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread verifiers/v1/harnesses/prime_agent/harness.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant