Skip to content

fix(workflows): persist structuredOutput on NodeOutput so $node.output.field works for Pi#1654

Merged
Wirasm merged 2 commits into
coleam00:devfrom
truffle-dev:fix/workflows-persist-structured-output-on-node-output-1571
May 13, 2026
Merged

fix(workflows): persist structuredOutput on NodeOutput so $node.output.field works for Pi#1654
Wirasm merged 2 commits into
coleam00:devfrom
truffle-dev:fix/workflows-persist-structured-output-on-node-output-1571

Conversation

@truffle-dev

@truffle-dev truffle-dev commented May 12, 2026

Copy link
Copy Markdown
Contributor

Re-target of #1637 onto dev per @Wirasm. No code change; rebased onto upstream/dev. Branch tip: fcb33ed4 (ED25519 signed). All Validation Evidence below re-run on the rebased branch.



Summary

  • Problem: When a provider parses fence-wrapped or preamble-prefixed JSON onto the result chunk (Pi/Minimax via tryParseStructuredOutput), the DAG executor captures it into a local variable but never persists it onto NodeOutput. Downstream $node.output.field substitution and when: conditions then try JSON.parse(output) on the original prose-prefixed text, throw, and resolve to empty.
  • Why it matters: every Pi/Minimax workflow with a downstream node that reads $classifier.output.type (or any other field) silently routes to the wrong branch or drops the value.
  • What changed: structuredOutput?: unknown added to the completed/running and failed NodeOutput branches; producer call sites persist it; consumers (substituteNodeOutputRefs, condition-evaluator.resolveOutputRef) prefer it over JSON.parse(output) and fall back to the existing path when absent.
  • What did not change: bare $node.output (unfielded) still reads output text. Cross-resume rehydration from event_data is out of scope (resumed runs that re-execute downstream nodes fall through to JSON.parse). Providers that already overwrite output with a JSON.stringify of structuredOutput (Claude/Codex with output_format) are unaffected because their output is already JSON.

UX Journey

Before

Pi/Minimax DAG with downstream $.field reads

  classify (provider=pi, prompt asks for JSON)
    ├─ raw chunk:        "Here is the classification:\n{\"type\":\"BUG\"}"
    ├─ tryParseStructuredOutput → { type: "BUG" }
    ├─ stored:           NodeOutput.output = raw text, structuredOutput = DROPPED
    │
  triage (depends_on: classify, when: $classify.output.type == 'BUG')
    └─ resolveOutputRef:
         JSON.parse("Here is the classification:\n{...}")  ── throws
         returns ''
         condition: '' == 'BUG' → false → SKIPPED  ❌

After

Pi/Minimax DAG with downstream $.field reads

  classify (provider=pi, prompt asks for JSON)
    ├─ raw chunk:        "Here is the classification:\n{\"type\":\"BUG\"}"
    ├─ tryParseStructuredOutput → { type: "BUG" }
    ├─ stored:           NodeOutput.output = raw text,
                         [NodeOutput.structuredOutput = { type: "BUG" }]
    │
  triage (depends_on: classify, when: $classify.output.type == 'BUG')
    └─ resolveOutputRef:
         [prefers NodeOutput.structuredOutput.type]  → "BUG"
         condition: 'BUG' == 'BUG' → true → RUNS  ✓

Architecture Diagram

Before

   ┌───────────────────────────┐
   │  providers (pi/minimax)   │
   │  emits result chunk with  │
   │  structuredOutput field   │
   └───────────┬───────────────┘
               │
               ▼
   ┌───────────────────────────┐
   │     dag-executor.ts       │
   │   captures msg.struct-    │
   │   uredOutput in local     │
   │   var (line ~913)         │
   │                           │
   │   on success return:      │
   │   { state, output,        │
   │     sessionId, costUsd }  │── structuredOutput dropped
   └───────────┬───────────────┘
               │
               ▼
   ┌───────────────────────────┐    ┌───────────────────────────┐
   │ substituteNodeOutputRefs  │    │   condition-evaluator     │
   │   JSON.parse(output)      │    │   JSON.parse(output)      │
   │   throws on prose preamble│    │   throws on prose preamble│
   └───────────────────────────┘    └───────────────────────────┘

After

   ┌───────────────────────────┐
   │  providers (pi/minimax)   │
   │  emits result chunk with  │
   │  structuredOutput field   │
   └───────────┬───────────────┘
               │
               ▼
   ┌───────────────────────────┐
   │     dag-executor.ts       │
   │   captures msg.struct-    │
   │   uredOutput in local     │
   │   var (line ~913)         │
   │                           │
   │   on success return:      │
   │   { state, output,        │
   │     sessionId, costUsd,   │
   │  [~ structuredOutput ] }  │── persisted
   └───────────┬───────────────┘
               │
               ▼
   ┌───────────────────────────┐    ┌───────────────────────────┐
   │ substituteNodeOutputRefs  │    │   condition-evaluator     │
   │ [~ prefer structuredOut]  │    │ [~ prefer structuredOut]  │
   │   falls back to           │    │   falls back to           │
   │   JSON.parse(output)      │    │   JSON.parse(output)      │
   └───────────────────────────┘    └───────────────────────────┘

Connection inventory:

From To Status Notes
dag-executor (single-shot success return) NodeOutput.structuredOutput new spread when defined
dag-executor (loop terminal-iteration return) NodeOutput.structuredOutput new new lastIterationStructuredOutput local
substituteNodeOutputRefs NodeOutput.structuredOutput new preferred over JSON.parse
condition-evaluator.resolveOutputRef NodeOutput.structuredOutput new preferred over JSON.parse
nodeOutputSchema (completed/running, failed) z.unknown().optional() new field back-compat

Label Snapshot

  • Risk: risk: low
  • Size: size: S
  • Scope: workflows
  • Module: workflows:executor, workflows:condition-evaluator, workflows:schemas

Change Metadata

  • Change type: bug
  • Primary scope: workflows

Linked Issue

Validation Evidence (required)

bun run --filter @archon/workflows test       # all green (workflows package)
bun --filter '*' test                          # all packages green
bun run --filter @archon/workflows type-check  # 0 errors
bun run type-check                             # full repo, 0 errors
bun run format:check                           # All matched files use Prettier code style!
bun x eslint .                                 # 0 errors
bun run check:bundled                          # up to date
bun run check:bundled-skill                    # up to date

New tests added:

  • condition-evaluator.test.ts: 12 new tests covering structuredOutput preference, Claude/Codex JSON.parse fallback regression, numeric/boolean/object/array field coercion, null/top-level-array/primitive fall-through, missing-field semantics, and bare $node.output (no field) still uses output text.
  • dag-executor.test.ts: 13 new tests for substituteNodeOutputRefs covering the same matrix plus shell-escaping safety on structuredOutput fields.

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No

Compatibility / Migration

  • Backward compatible? Yes. structuredOutput is optional on the schema; producers populate it only when the provider emits one; consumers fall through to the existing JSON.parse(output) path when it is absent. Workflows that depend on the JSON.parse path keep working unchanged.
  • Config/env changes? No
  • Database migration needed? No. NodeOutput is stored inline in workflow event data; older rows without structuredOutput deserialize as undefined and hit the JSON.parse fallback.
  • Resume note: on a paused-then-resumed workflow run, downstream nodes that re-execute against an already-completed upstream will rehydrate NodeOutput from event_data. If the original run was on this version, the field is preserved; if older, it is absent and the JSON.parse fallback runs. Cross-version resume of Pi workflows that previously routed wrong will route correctly once the upstream re-runs on the new version.

Human Verification (required)

  • Verified scenarios:
    • Pi-shape: prose output + populated structuredOutput → dot-access reads structuredOutput.
    • Claude/Codex output_format-shape: empty/JSON output, no structuredOutput → JSON.parse fallback still works.
    • Loop terminal iteration: structuredOutput from the final iteration is what survives onto NodeOutput.
    • Bare $node.output (no field) ignores structuredOutput and reads output text.
    • Missing field on structuredOutput → empty string (does not silently retry JSON.parse).
    • null / top-level-array / primitive structuredOutput → falls through to JSON.parse (matches existing edge-case semantics).
    • Shell-escaping path (escapedForBash=true) wraps structuredOutput strings/objects in single quotes; numbers/booleans stay unquoted.
  • Edge cases checked: empty output text combined with populated structuredOutput (Pi-only-structured case) still resolves the field correctly because the empty-output early-return was moved inward.
  • What was not verified by hand: end-to-end Pi workflow against the live provider (the change is verifiable via unit tests against the consumer call sites and the producer return shape).

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: any DAG workflow with a $node.output.field substitution or a when: expression that uses dot notation.
  • Potential unintended effects: a workflow author who currently relies on JSON.parse(output) returning a different shape than what the provider's parsed structuredOutput contains (e.g. provider parses one JSON object out of multiple in the prose) would see a behavior change. In practice the providers that emit structuredOutput are emitting the canonical structured payload that the prompt asked for, so the change is the intended fix, not a surprise.
  • Guardrails: existing log lines (condition_json_parse_failed, dag_node_output_ref_json_parse_failed) still fire on the fallback path; no new error paths introduced.

Rollback Plan (required)

  • Fast rollback: revert the single commit on this PR. Schema field is optional and writing it on new rows does not break readers that don't know about it.
  • Feature flags: none. The change is mechanical and small enough that flagging it would add more risk than it removes.
  • Observable failure symptoms (if regression slipped through): $node.output.field would resolve to JSON-stringified content where it previously resolved to a plain string (would surface as condition mismatches or wrongly-formatted prompts on Claude/Codex output_format workflows).

Risks and Mitigations

  • Risk: a workflow that historically saw an empty string for a $node.output.field on a Pi/Minimax node (because the JSON.parse failed) might now see the resolved value and take a previously-unreachable branch. This is the intended behavior change for Parsed structured output not persisted to NodeOutput — condition evaluator and $node.output.field re-parse raw text, breaking Pi/Minimax #1571 but could surprise authors who built around the bug.
    • Mitigation: workflow authors who want the old empty-string behavior can keep relying on missing-field semantics (the new code returns '' when the structuredOutput object doesn't contain the requested key). The fix only changes the case where the provider actually parsed the structured payload the prompt requested.

Summary by CodeRabbit

  • New Features

    • Nodes can include provider-supplied structured output; condition checks and $node.output.field lookups prefer structured data when present.
    • AI-node and loop completions now surface structured output alongside plain text output.
  • Behavior Changes

    • Field substitutions handle numbers/booleans as strings and serialize objects/arrays; bare $node.output still uses raw text when applicable.
  • Tests

    • Added coverage for structuredOutput precedence, fallbacks, missing fields, and bash-escaped substitutions.

Review Change Stack

…t.field works for Pi

When a provider parses fence-wrapped or preamble-prefixed JSON onto the
result chunk (Pi/Minimax via tryParseStructuredOutput), the executor
captured it locally but never persisted it onto NodeOutput. Downstream
consumers (substituteNodeOutputRefs, condition-evaluator) then
JSON.parse(output)'d the original prose-prefixed text, which threw, and
$node.output.field resolved to empty.

This persists structuredOutput on NodeOutput (single-shot and
loop-terminal-iteration success paths) and teaches both consumers to
prefer the parsed object over re-parsing prose. Falls back to
JSON.parse(output) when structuredOutput is absent so Claude/Codex
output_format-encoded NodeOutput rows (and older rows written before
this field existed) keep working.

Cross-resume rehydration of structuredOutput from event_data is
out of scope here; resumed runs that re-execute downstream nodes
will fall through to the JSON.parse path, which matches existing
behavior.

Closes coleam00#1571
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d642bb6a-9d63-4c2b-becb-6bee0cd7e6c5

📥 Commits

Reviewing files that changed from the base of the PR and between fcb33ed and 7678d52.

📒 Files selected for processing (2)
  • packages/workflows/src/condition-evaluator.test.ts
  • packages/workflows/src/dag-executor.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/workflows/src/condition-evaluator.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/workflows/src/dag-executor.test.ts

📝 Walkthrough

Walkthrough

This PR introduces a structuredOutput field to the node output schema and updates condition evaluation, substitution, and execution logic to prefer this provider-emitted structured data over re-parsing output strings as JSON. The changes ensure that provider-produced JSON payloads are preserved exactly as sent, rather than being parsed and stringified repeatedly.

Changes

Structured Output Preference Throughout Resolution and Execution

Layer / File(s) Summary
Output schema definition with structuredOutput field
packages/workflows/src/schemas/workflow-run.ts
NodeOutput schema now includes an optional structuredOutput?: unknown field on state: 'completed' | 'running' and state: 'failed' variants, with documentation explaining precedence in downstream resolution.
Condition evaluator: dot-notation output preference
packages/workflows/src/condition-evaluator.ts, packages/workflows/src/condition-evaluator.test.ts
resolveOutputRef prefers nodeOutput.structuredOutput for dot-notation field access (e.g., $nodeId.output.field) when it is a non-array object, with type coercion for primitives and JSON stringification for arrays/objects. Returns empty string for missing fields or when falling back to JSON parsing. Test helper accepts structuredOutput parameter; new test suite validates precedence, fallback behavior, and field resolution rules.
DAG executor substitution: structured output preference
packages/workflows/src/dag-executor.ts, packages/workflows/src/dag-executor.test.ts
substituteNodeOutputRefs prioritizes nodeOutput.structuredOutput for variable substitution, applies bash-safe escaping to structured values, and falls back to JSON parsing when structuredOutput is missing or invalid. Test helper updated to support structuredOutput; new test suite covers primitive coercion, array/object stringification, null handling, missing-field rules, and shell-escaping for structured values.
Execution result propagation of structuredOutput
packages/workflows/src/dag-executor.ts
AI node execution (executeNodeInternal) includes structuredOutput in returned results when provided. Loop execution (executeLoopNode) captures the most recent iteration's structuredOutput and returns it on successful completion, enabling downstream resolution to prefer the structured form.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • coleam00/Archon#1426: Related—both PRs touch $node.output substitution in dag-executor and substituteNodeOutputRefs; the main PR adds structured output preference while the retrieved PR wires substitution into approval message rendering.
  • coleam00/Archon#1297: Directly related—main PR updates workflows to consume and prefer nodeOutput.structuredOutput; retrieved PR makes a provider produce structuredOutput (producer–consumer relationship).
  • coleam00/Archon#1482: Related—both PRs modify output resolution and substitution logic to change how non-scalar outputs are handled; this PR advances that by preferring provider-provided structured output before JSON parsing.

Poem

🐰 Structured outputs flow so clean,
No more parsing schemes between,
From provider hand to condition's test,
The JSON truth is now preserved best!
hops away with bundled types

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix: persisting structuredOutput on NodeOutput to enable downstream field access ($node.output.field) for Pi provider workflows.
Description check ✅ Passed The PR description comprehensively covers all required template sections: problem/impact/changes/scope, before/after UX journey, architecture diagrams, metadata, linked issue, extensive validation evidence with test counts, security/compatibility/human verification/side effects/rollback plans.
Linked Issues check ✅ Passed The PR fully addresses issue #1571 by persisting structuredOutput on NodeOutput schema, updating consumer call sites (condition-evaluator and substituteNodeOutputRefs) to prefer it, and adding comprehensive test coverage for preference/fallback/edge cases.
Out of Scope Changes check ✅ Passed All changes directly support the stated objectives: schema updates, DAG executor persistence/loop-terminal-iteration handling, condition-evaluator and substituteNodeOutputRefs preference logic, and tests. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 87.50% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (3)
packages/workflows/src/dag-executor.ts (1)

302-321: 💤 Low value

Clarify comment at line 320 to match the accurate version at line 335.

Similar to the issue in condition-evaluator.ts, the comment at line 320 states // null, undefined, symbol, bigint → empty, but null field values are actually JSON-stringified at line 317 (because typeof null === 'object').

The comment at line 335 (in the JSON.parse fallback path) correctly notes "null is caught above by typeof check". Line 320 should use the same accurate phrasing for consistency.

Suggested clarification
-      return escapedForBash ? "''" : '';
+      return escapedForBash ? "''" : ''; // undefined, symbol, bigint → empty (null is caught above by typeof check)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/workflows/src/dag-executor.ts` around lines 302 - 321, Update the
misleading inline comment near the structuredOutput handling (around the block
using the `structured` variable and the field extraction) so it matches the
accurate wording used in the JSON.parse fallback: explicitly note that null is
handled above by the typeof/object check rather than being treated as "empty";
e.g., change the remark that currently says "null, undefined, symbol, bigint →
empty" to indicate "undefined, symbol, bigint → empty (null is caught above by
typeof check)" so the intent is consistent with the fallback path.
packages/workflows/src/condition-evaluator.ts (2)

48-63: 💤 Low value

Clarify comment: null is JSON-stringified, not coerced to empty string.

The comment at line 62 states // null, undefined, symbol, bigint → empty, but this is misleading for null. Because typeof null === 'object' in JavaScript, a null field value matches the condition at line 61 and returns JSON.stringify(null) (the string "null"), not an empty string. Only undefined, symbol, and bigint reach line 62.

The code behavior is correct (confirmed by test at lines 416-420), but the comment could confuse future maintainers.

Suggested clarification
-    return ''; // null, undefined, symbol, bigint → empty
+    return ''; // undefined, symbol, bigint → empty (null is JSON-stringified above)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/workflows/src/condition-evaluator.ts` around lines 48 - 63, The
comment describing fallback behavior for values in the structuredOutput branch
is inaccurate about null; update the comment near the structuredOutput handling
in condition-evaluator.ts (the block that checks const structured =
'structuredOutput' in nodeOutput ? nodeOutput.structuredOutput : undefined and
then inspects value) to clarify that null will be JSON.stringify'd (resulting in
"null") while undefined, symbol, and bigint fall through to the empty-string
return; keep existing logic unchanged and only adjust the explanatory comment to
accurately reflect these cases.

65-82: 💤 Low value

Same comment issue as above: clarify null handling.

The comment at line 74 has the same misleading phrasing as line 62. A null field value is JSON-stringified (line 73), not coerced to empty string.

Suggested clarification
-    return ''; // null, undefined, symbol, bigint → empty
+    return ''; // undefined, symbol, bigint → empty (null is JSON-stringified above)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/workflows/src/condition-evaluator.ts` around lines 65 - 82, The
comment claiming "null, undefined, symbol, bigint → empty" is misleading because
null currently matches the Array.isArray(value) || typeof value === 'object'
branch and is JSON.stringify'd, producing "null"; update the comment near the
fallback parsing logic to state that null will be JSON.stringify'd (resulting in
"null") while undefined, symbol, and bigint return '' and ensure the wording
around parsed/value and the Array.isArray(value) || typeof value === 'object'
branch (and the related earlier comment) clearly reflect this behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/workflows/src/condition-evaluator.ts`:
- Around line 48-63: The comment describing fallback behavior for values in the
structuredOutput branch is inaccurate about null; update the comment near the
structuredOutput handling in condition-evaluator.ts (the block that checks const
structured = 'structuredOutput' in nodeOutput ? nodeOutput.structuredOutput :
undefined and then inspects value) to clarify that null will be JSON.stringify'd
(resulting in "null") while undefined, symbol, and bigint fall through to the
empty-string return; keep existing logic unchanged and only adjust the
explanatory comment to accurately reflect these cases.
- Around line 65-82: The comment claiming "null, undefined, symbol, bigint →
empty" is misleading because null currently matches the Array.isArray(value) ||
typeof value === 'object' branch and is JSON.stringify'd, producing "null";
update the comment near the fallback parsing logic to state that null will be
JSON.stringify'd (resulting in "null") while undefined, symbol, and bigint
return '' and ensure the wording around parsed/value and the
Array.isArray(value) || typeof value === 'object' branch (and the related
earlier comment) clearly reflect this behavior.

In `@packages/workflows/src/dag-executor.ts`:
- Around line 302-321: Update the misleading inline comment near the
structuredOutput handling (around the block using the `structured` variable and
the field extraction) so it matches the accurate wording used in the JSON.parse
fallback: explicitly note that null is handled above by the typeof/object check
rather than being treated as "empty"; e.g., change the remark that currently
says "null, undefined, symbol, bigint → empty" to indicate "undefined, symbol,
bigint → empty (null is caught above by typeof check)" so the intent is
consistent with the fallback path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 03392aec-066f-436b-b7c5-314cc215be2d

📥 Commits

Reviewing files that changed from the base of the PR and between dffefd6 and fcb33ed.

📒 Files selected for processing (5)
  • packages/workflows/src/condition-evaluator.test.ts
  • packages/workflows/src/condition-evaluator.ts
  • packages/workflows/src/dag-executor.test.ts
  • packages/workflows/src/dag-executor.ts
  • packages/workflows/src/schemas/workflow-run.ts

@Wirasm

Wirasm commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Review Summary

Verdict: ready-to-merge

This is a solid bug fix. The PR adds structuredOutput to NodeOutput, wires both field-resolution consumers to prefer it over re-parsing prose output, and includes comprehensive tests for every edge case. It's focused, backward-compatible, and the PR description is a model of clarity. No blocking issues.

Blocking issues

None.

Suggested fixes

None.

Minor / nice-to-have

  • condition-evaluator.test.ts:24–29 and dag-executor.test.ts:185–190: The makeOutput fixture docstrings are 5–6 lines each. They explain real subtleties, but could be tightened to 1–2 lines per CLAUDE.md multi-line comment guidance.
  • condition-evaluator.test.ts:370: Section divider is verbose — // structuredOutput preference suffices.
  • condition-evaluator.ts:41–42: Drop "fail-closed" framing; "For unfielded ref, structuredOutput shape is opaque — defer to output text." is cleaner and accurate.

Compliments

  • The structuredOutput preference logic comments in both production files (condition-evaluator.ts:49–52, dag-executor.ts:302–305) do exactly what good comments should: explain the why without restating the what.
  • Edge-case coverage is thorough — structuredOutput === null, array/primitive at top level, missing field → no JSON.parse retry, shell-quoting with escapedForBash, bare $node.output semantics. The test count is justified by the number of distinct paths.
  • PR description quality is excellent — architecture diagrams, before/after journeys, validation evidence, security/compatibility verification. Sets a high bar.

Reviewed via maintainer-review-pr workflow (Pi/Minimax). Aspects run: code-review, test-coverage, comment-quality.

@Wirasm Wirasm merged commit 19573df into coleam00:dev May 13, 2026
4 checks passed
@coleam00 coleam00 mentioned this pull request May 14, 2026
cropse pushed a commit to cropse/Archon that referenced this pull request May 19, 2026
…t.field works for Pi (coleam00#1654)

* fix(workflows): persist structuredOutput on NodeOutput so $node.output.field works for Pi

When a provider parses fence-wrapped or preamble-prefixed JSON onto the
result chunk (Pi/Minimax via tryParseStructuredOutput), the executor
captured it locally but never persisted it onto NodeOutput. Downstream
consumers (substituteNodeOutputRefs, condition-evaluator) then
JSON.parse(output)'d the original prose-prefixed text, which threw, and
$node.output.field resolved to empty.

This persists structuredOutput on NodeOutput (single-shot and
loop-terminal-iteration success paths) and teaches both consumers to
prefer the parsed object over re-parsing prose. Falls back to
JSON.parse(output) when structuredOutput is absent so Claude/Codex
output_format-encoded NodeOutput rows (and older rows written before
this field existed) keep working.

Cross-resume rehydration of structuredOutput from event_data is
out of scope here; resumed runs that re-execute downstream nodes
will fall through to the JSON.parse path, which matches existing
behavior.

Closes coleam00#1571

* test(workflows): docstring the structuredOutput makeOutput fixtures
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.

Parsed structured output not persisted to NodeOutput — condition evaluator and $node.output.field re-parse raw text, breaking Pi/Minimax

2 participants