Skip to content

fix(opencode): implement reformatOutput so structured-output agents recover - #803

Merged
tomdps merged 29 commits into
the-open-engine:mainfrom
PaysNatal:fix/opencode-reformat-output
Jul 29, 2026
Merged

fix(opencode): implement reformatOutput so structured-output agents recover#803
tomdps merged 29 commits into
the-open-engine:mainfrom
PaysNatal:fix/opencode-reformat-output

Conversation

@PaysNatal

Copy link
Copy Markdown
Contributor

Problem

When an agent runs on the opencode provider and is required to emit structured JSON (any agent with a jsonSchema, e.g. the planner in full-workflow), the cluster frequently dies with:

🔴 AGENT OUTPUT MISSING REQUIRED JSON BLOCK
Agent: planner, Role: planning, Provider: opencode
Error: Agent planner output missing required JSON block

The cluster's backing process exits and the cluster becomes a zombie, failing the whole task.

Root cause

The opencode planner spends its turn on tool-calls (reading files to plan) and does not emit the final JSON plan block as text. extractJsonFromOutput therefore finds nothing to parse.

parseResultOutput then falls back to reformatOutput — the mechanism designed exactly for "the agent produced non-JSON output, reformat it into the schema". But reformatOutput was shipped as a stub that always rejects:

Output reformatting not available: SDK not implemented for provider "opencode". Agent output must be valid JSON.

So both the primary extraction and the fallback fail → the agent fails → zombie cluster.

Note: TRIVIAL/SIMPLE tasks skip the planner, so they are unaffected. Only tasks complex enough to invoke a structured-output agent (planner, etc.) hit this.

Fix

Implement reformatOutput using the opencode CLI as the reformatting backend (opencode reliably emits clean JSON via --format json). Two details were essential:

  1. Disable tools in the reformat prompt. Without an explicit "do NOT use any tools / do NOT read files" instruction, opencode sees filenames mentioned in the raw output and re-enters tool-use, hanging the reformat call (>120s observed vs ~15s with the instruction).

  2. Spawn with stdin ignored. execFile leaves the child's stdin as an open pipe; opencode blocks waiting for stdin EOF and never returns. Using spawn with stdio: ['ignore', 'pipe', 'pipe'] fixes this. A timeout + SIGKILL guards against any residual hang.

The reformat result is still run through extractJsonFromOutput and validated against the agent's jsonSchema (existing validateAgainstSchema), with up to maxAttempts retries.

Verification

  • Unit: feeding a tool-call-style raw output + a schema to reformatOutput returns schema-valid JSON in ~14s.
  • End-to-end: a complex opencode task that previously produced a zombie cluster (planner failing with "missing required JSON block") now passes the planner and progresses (planner completes, worker starts) instead of dying.

Notes / discussion

  • The reformatting backend is hardcoded to the opencode binary. If a general provider.callSimple SDK lands later, this can switch to the commented design (reformat via the agent's own provider). Happy to adjust to whichever direction maintainers prefer.
  • This makes the fallback functional for opencode today without waiting on a full SDK implementation.

…ecover

The reformatOutput fallback was a stub that always threw "SDK not implemented",
so when an opencode agent (e.g. the planner) emitted non-JSON output — it spends
its turn on tool-calls instead of emitting the final JSON block — primary
extraction failed and the cluster died with "Agent ... output missing required
JSON block" (becoming a zombie). Trivial tasks skip the planner, so only
complex tasks were affected.

Implement reformatOutput using the opencode CLI as the reformatting backend:
- Add a strict "do not use tools / do not read files" instruction to the
  reformat prompt; otherwise opencode re-enters tool-use on filenames mentioned
  in the raw output and hangs (>120s vs ~15s).
- Spawn the CLI with stdin ignored (stdio:['ignore','pipe','pipe']); execFile
  leaves stdin as an open pipe and opencode blocks waiting for stdin EOF.

Verified: unit test (reformat yields schema-valid JSON in ~14s) and end-to-end
(a complex opencode task now passes the planner and progresses instead of
becoming a zombie).
@greptile-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements OpenCode structured-output recovery and adds lifecycle-safe nested execution support.

  • Runs schema reformatting as a nested OpenCode task with a mechanically tool-disabled formatter profile.
  • Tracks nested task identities separately from parent executions and integrates cancellation, deadlines, shutdown, and liveness handling.
  • Caches validated structured results for hooks, template substitution, and PR verification.
  • Adds coverage for reformatting, nested execution lifecycle, cancellation, isolation, and model provenance.

Confidence Score: 3/5

The PR is not yet safe to merge because structured-output recovery still fails for configured providers other than OpenCode.

The shared parser invokes the fallback for every structured-output provider, but the fallback explicitly rejects non-OpenCode providers and consequently reaches the same missing-required-JSON terminal failure.

Files Needing Attention: src/agent/output-reformatter.js and src/agent/agent-task-executor.js

Important Files Changed

Filename Overview
src/agent/output-reformatter.js Implements retryable OpenCode-only schema reformatting with cancellation-aware error propagation.
src/agent/agent-task-executor.js Adds nested task spawning, formatter tool boundaries, structured-result caching, and normal/isolated cancellation integration.
src/agent/task-execution-handle.js Introduces independent nested execution ownership, deadlines, cancellation settlement, and fail-closed retention.
src/agent/agent-lifecycle.js Extends shutdown and liveness recovery to include active nested executions.
task-lib/runner.js Forwards the generated OpenCode formatter-agent identity through the provider command boundary.
tests/output-reformatter.test.js Covers OpenCode reformat success, validation retries, cancellation, and the explicit rejection of other providers.
tests/task-execution-lifecycle.test.js Exercises nested execution cancellation, timeout, ownership retention, and lifecycle propagation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Provider task completes] --> B{Structured JSON extracted?}
  B -->|Yes| C[Validate and cache parsed result]
  B -->|No, OpenCode schema task| D[Register nested execution handle]
  D --> E[Launch tool-disabled formatter agent]
  E --> F{Formatter returns valid JSON?}
  F -->|Yes| C
  F -->|No| G[Retry within configured attempt limit]
  G --> E
  D --> H[Stop, timeout, or liveness cancellation]
  H --> I[Terminate nested durable task]
  I --> J{Termination confirmed?}
  J -->|Yes| K[Settle and unregister handle]
  J -->|No| L[Retain ownership and fail closed]
  C --> M[Hooks and result substitution reuse cache]
Loading

Reviews (22): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread src/agent/output-reformatter.js Outdated
Comment thread src/agent/output-reformatter.js Outdated

@tomdps tomdps left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The reported root cause is real: the previous reformatOutput path always rejected after structured extraction failed, so tool-only OpenCode output fell through to Agent ... output missing required JSON block.

I approved and ran the external-fork workflows, preserved PaysNatal's original commit/authorship, and had two independent review passes over the updated branch. The current head is not safe to merge. Maintainer follow-up commits exposed a structural lifecycle gap that cannot be closed by snapshotting the existing singleton task fields.

Required changes:

  1. Create a first-class reentrant task-execution handle before launch. src/agent/agent-task-executor.js:881-887 starts the local wrapper before an owned ID/handle exists; :932-937, :671-703, and :1455 assign the ID and follower later. The isolated path has the same gap at :1573, :1620, and :2085. Cancellation or setup failure in those windows can orphan a provider task, and a retry can overlap it. The handle must own local and isolated launches before process creation, accept cancellation before the task ID exists, clean up a task whose ID arrives late, terminate the owned process tree, and await settlement before retry/rethrow.

  2. Keep parent and formatter identities separate. The nested launch at src/agent/agent-task-executor.js:2228-2229 currently reuses AgentWrapper.currentTask/currentTaskId/processPid. That overwrites the parent ID used by TASK_COMPLETED, TOKEN_USAGE, hooks, resume, and diagnostics. Use immutable parent/child identities on the execution handle; do not snapshot/restore shared globals.

  3. Reuse the validated recovery object. src/agent/agent-task-executor.js:1213-1216 currently returns the original unparsable output after recovery. Completion hooks then parse/reformat a second time, adding another model call and potentially publishing a different result. Carry the validated parsed object through local and isolated completion and make {{result.*}} substitution consume that cached object.

  4. Preserve cancellation identity end to end. REFORMAT_CANCELLED must remain cancellation through parseResultOutput; it must never become a missing-JSON error.

Focused regression coverage must include: local and isolated cancellation during pre-ID registration; failure after task creation but before follower installation; late-ID cleanup; no retry before tree settlement; one recovery model call across validation and hooks; explicit-model/isolation provenance; and parent task IDs retained in completion/token/hook metadata.

The current AgentWrapper has only one task ownership slot, and IsolationManager has no atomic nested-launch handle, so this needs the lifecycle contract above rather than another special-case spawn path. Please update this same contributor PR; no replacement PR or authorship rewrite is needed.

Address maintainer review (4 required changes):

1. First-class reentrant TaskExecutionHandle: created before process spawn,
   owns the process tree, accepts cancellation before task ID/PID exist,
   cleans up late-arriving task IDs, settles before retry.

2. Parent/child identity separation: nested reformat launches pass
   nested:true and never overwrite agent.currentTask/currentTaskId/processPid.
   Parent identity is immutable across the nested execution.

3. Cached parsed recovery object: evaluateStructuredSuccess stores the
   validated parse on state._cachedParsedResult; buildCompletionResult
   returns it as parsedResult so hooks never re-parse (one model call).

4. Cancellation identity end-to-end: REFORMAT_CANCELLED propagates through
   parseResultOutput as cancellation, never becomes a missing-JSON error.

Regression tests: pre-ID cancellation, late-attach kill, parent identity
retention, single recovery model call, cancellation between attempts.
…mer cleanup

- Guard isolated path liveness monitoring and log output with !nested
  (previously unreachable but now consistent with local path guards)
- Short-circuit evaluateStructuredSuccess when _cachedParsedResult is
  already populated, preventing a redundant second parse/model call
- Store SIGKILL timer reference in TaskExecutionHandle and clear it on
  process settle to avoid dangling timers
- Update regression test to verify cache short-circuit behavior
@PaysNatal

Copy link
Copy Markdown
Contributor Author

@tomdps Thanks for the thorough review and the structural guidance. All 4 required changes are implemented in 15ed4dd, with a follow-up hardening pass in 8efaf88:

  1. Reentrant handleTaskExecutionHandle (new file src/agent/task-execution-handle.js) is created before spawnTaskProcess; supports pre-ID cancellation, late-attach kill, and SIGTERM→SIGKILL settlement with proper timer cleanup on process exit.

  2. Parent/formatter identity separationnested: true propagates through spawnClaudeTask → spawnTaskProcess → createLogFollower (local) and spawnClaudeTaskIsolated → followClaudeTaskLogsIsolated (isolated). Agent globals (currentTask, currentTaskId, processPid) are never touched for nested launches. Lifecycle events, liveness monitoring, and log output are all guarded.

  3. Cached recovery objectevaluateStructuredSuccess stores the validated parse in state._cachedParsedResult and short-circuits if the cache is already populated (no redundant second parse). buildCompletionResult returns it as parsedResult so hooks and {{result.*}} substitution consume it directly.

  4. Cancellation identityREFORMAT_CANCELLED re-throws unchanged through parseResultOutput; reformatOutput checks isCancelled() before each attempt and after each result. Regression tests verify the error code never degrades to "missing required JSON block".

Coverage: 9 new regression tests in tests/task-execution-lifecycle.test.js (pre-ID cancel, late-attach kill, immutable assignment, settle, parent identity preservation, single-parse caching, cache short-circuit, cancellation propagation, no-retry-after-cancel). All 32 tests pass. Lint and typecheck clean.

Additional hardening in 8efaf88:

  • Isolated path liveness/log now guarded with !nested (consistent with local path)
  • evaluateStructuredSuccess short-circuits on existing _cachedParsedResult
  • TaskExecutionHandle stores and clears the SIGKILL timer on process settle
  • Fixed pre-existing lint errors in touched test files (unused async, dead store)

Ready for re-review. Let me know if you would like me to rebase onto current main first.

@tomdps tomdps left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved after independent exact-head review. The OpenCode structured-output recovery, model/config provenance, cancellation and durable cleanup ownership paths are correct; the timing-only test race is now deterministic. Exact e527dca passed required, check, CodeQL, semantic, both install matrices, Release preflight, and Greptile.

@tomdps
tomdps enabled auto-merge July 29, 2026 19:40
@tomdps
tomdps added this pull request to the merge queue Jul 29, 2026
Merged via the queue into the-open-engine:main with commit 17028d9 Jul 29, 2026
10 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 6.12.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants