Skip to content

fix(agents): recover pi JSON output that follows prose#155

Open
cjunxiang wants to merge 3 commits into
kunchenguid:mainfrom
cjunxiang:fix/pi-recover-json-output
Open

fix(agents): recover pi JSON output that follows prose#155
cjunxiang wants to merge 3 commits into
kunchenguid:mainfrom
cjunxiang:fix/pi-recover-json-output

Conversation

@cjunxiang

@cjunxiang cjunxiang commented Jun 21, 2026

Copy link
Copy Markdown

Summary

gnhf --agent pi fails every iteration with Failed to parse pi output: ... is not valid JSON, trips maxConsecutiveFailures, and aborts — even though the model did emit valid JSON. PiAgent was the only adapter parsing its final assistant message with raw JSON.parse; this routes it through the shared parseAgentOutput like every other adapter, recovering JSON that follows a prose summary or is wrapped in fences.

Closes #154.

Two commits:

  1. fix(agents): recover pi JSON output that follows prose — the bug fix + regression test.
  2. refactor(agents): share parseAgentOutput helper across adapters — extract the now-triplicated parseAgentJson + validateAgentOutput fallback into one parseAgentOutput(text, schema, agentLabel) in types.ts; route pi, opencode, and copilot through it. Net −44 lines.

Root cause

PiAgent was the only built-in adapter that used raw JSON.parse on the final assistant message. acp, copilot, opencode, and rovodev all route through parseAgentJson (src/core/agents/json-extract.ts), which strips Markdown fences and recovers the rightmost balanced JSON object — i.e. it tolerates prose/fences around the JSON.

Pi frequently writes a short prose summary before the JSON object. The final assistant message captured in a real failing run was:

Iteration 3 complete. Established a clean, building petpet foundation ...

{"success":true,"summary":"...","key_changes_made":[...],"key_learnings":[...]}

The leading Iteration 3 complete. makes JSON.parse(finalText) throw Unexpected token 'I', "Iteration "... is not valid JSON, so the iteration is recorded as a failure. Reproduced across three consecutive iterations ("Build gree...", "All work f...", "Iteration "). Verified that parseAgentJson recovers the object from that exact text (all four required keys present, success: true).

This is also why the run appeared "stuck": the guaranteed 3 failures abort the orchestrator, after which the TUI stays open (keepTui = status === "aborted" && isTTY) with the elapsed timer still incrementing, so a stopped run looks still-active until Ctrl+C.

The fix

parseAgentOutput(text, schema, agentLabel) (now in types.ts, next to validateAgentOutput):

  1. parseAgentJson(text, validateAgentOutput-as-predicate) — returns the first (rightmost) JSON object that validates.
  2. No-predicate fallback parseAgentJson(text) — so schema-invalid JSON still surfaces as Invalid pi output (via a re-thrown validation error) rather than being miscategorized as Failed to parse pi output.
  3. throw new SyntaxError(...) when no JSON is present at all → preserved as Failed to parse pi output.

The refactor preserves behavior exactly: the per-agent no-JSON error string is retained via agentLabel, and each adapter keeps its own caller-side error formatting (pi distinguishes Failed vs Invalid; opencode/copilot use a single message). Existing error messages are unchanged:

  • "not json" (no object) → Failed to parse pi output
  • valid JSON, wrong shape → Invalid pi output

Verification

  • tsc --noEmit — clean
  • vitest run (unit suite, excluding e2e/) — 619/619 passing, including a new regression test recovers JSON that follows a prose summary
  • eslint + prettier --check on changed files — clean

PiAgent parsed its final assistant message with raw JSON.parse, unlike
every other adapter (acp, copilot, opencode, rovodev) which route through
parseAgentJson. When the pi agent prefixes a short prose summary before
the JSON object (e.g. "Iteration 3 complete. ..."), JSON.parse throws on
the first non-JSON token, every iteration fails, and gnhf hits its
maxConsecutiveFailures limit and aborts.

Route PiAgent through parseAgentJson with the schema validator as the
accept predicate and a no-predicate fallback (mirroring opencode), so
schema-invalid JSON still surfaces as "Invalid pi output" instead of
"Failed to parse pi output". Add a regression test for prose-then-JSON.

Fixes kunchenguid#154
The parseAgentJson + validateAgentOutput fallback shape was duplicated
identically in parsePiOutput, parseOpenCodeOutput, and parseCopilotOutput
(only the function name and the no-JSON error string differed). Extract a
single parseAgentOutput(text, schema, agentLabel) next to
validateAgentOutput in types.ts and route all three adapters through it.

Behavior is preserved: the per-agent SyntaxError message is retained via
agentLabel, and each adapter keeps its own caller-side error formatting
(pi distinguishes Failed vs Invalid; opencode/copilot use a single message).
@cjunxiang cjunxiang marked this pull request as ready for review June 21, 2026 13:01
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.

pi: agent adapter rejects valid JSON when model prefixes a prose summary (3 consecutive failures → abort)

1 participant