Skip to content

fix(zcode): retry invalid structured output in-session before failing - #31

Closed
zhangweijian97 wants to merge 3 commits into
devos-ing:mainfrom
zhangweijian97:fix/zcode-structured-output-retry
Closed

fix(zcode): retry invalid structured output in-session before failing#31
zhangweijian97 wants to merge 3 commits into
devos-ing:mainfrom
zhangweijian97:fix/zcode-structured-output-retry

Conversation

@zhangweijian97

@zhangweijian97 zhangweijian97 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem

Under high effort, the model behind the ZCode app-server periodically answers a role turn in a form that fails structured-output extraction, burning the whole attempt as invalid_structured_output even though the session is still healthy. Live runs showed two failure shapes:

  1. JSON-less prose — a pure markdown report with no JSON object at all (observed live: a Review turn ending in a Chinese prose verification summary with no extractable payload).
  2. Schema-violating JSON — a JSON object that the role's strict schema rejects. Live evidence from a full Scout→Implement→Review run: the Review model repeatedly emitted {"$schema":"https://json-schema.org/draft/2020-12/schema","kind":"review","decision":"accepted",...} — it echoes the prompt's schema metadata as a $schema key and drops required fields like remainingGaps, so strict validation fails even though a JSON object is present.

The app-server protocol exposes no output-format control (no responseFormat-style parameter on session/*), so the constraint cannot be enforced at the protocol layer today.

Change

When a successful turn.completed carries a response that does not decode into a schema-valid role output — either shape above — the harness sends one in-session correction message (repeating the exact role schema) and waits for the next completion instead of failing:

  • the trigger is now decodeStructuredOutput(role, response) === undefined: no extractable JSON object or a JSON object that violates the strict role schema;
  • up to 2 correction rounds per attempt, shared across both shapes (one budget, no separate counters), then the existing invalid_structured_output failure path runs unchanged;
  • the counter lives on the in-memory attempt state, because a restart already abandons the in-flight turn (orphaned_turn), so no cursor migration is needed;
  • the correction prompt is role-aware (structuredOutputRetryPrompt in prompts.ts); its wording now covers both shapes ("contained no JSON object matching the required schema");
  • Implement commit resolution is deliberately outside the retry: a schema-valid draft whose commit cannot be resolved is a workspace problem (invalid_implementation_commit) that restating the schema cannot correct.

This mirrors the tolerance-extraction self-rescue already accepted for the Pi backend (f97b51d): when the protocol cannot guarantee structured output, the backend recovers in-session instead of terminalizing.

Testing

  • new test: a JSON-less final response triggers exactly one correction send and the attempt still completes with the role output;
  • new test: persistently JSON-less responses exhaust the two retries and fail with invalid_structured_output;
  • new test: a schema-violating JSON response (live $schema-pollution shape) triggers one correction and the retried reply completes;
  • new test: mixed JSON-less and schema-violating responses share the single two-correction budget and still fail closed;
  • conformance case updated: it scripts three invalid turns so retrying backends (ZCode) consume one per correction round while non-retrying backends fail on the first;
  • full suite: bun test 406 pass / 0 fail, tsc --noEmit clean.

A successful turn whose final response contains no JSON object used to
terminalize the whole attempt as invalid_structured_output. High-effort
models periodically reply with a prose report instead, so the harness now
sends a schema-repeating correction message into the same session and
waits for the next completion, up to two times, before failing.

The retry counter is ephemeral: a restart already abandons the in-flight
turn, so no cursor migration is needed.
@0xroylee

0xroylee commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

已使用 code-review 技能完成 Standards / Spec 双轴审查,审查 head 为 0db3d4b75092055ce7f07a40ce73680419cf021b。发布前确认 head 未变化。

当前建议:修复以下两项 P2 问题,并让完整检查通过后再合并。

Spec

[P2] 纠正轮次没有保留已报告的 token 用量

位置:src/agents/zcode/harness.ts:837-859

JSON-less completion 进入纠正分支后,其 event.data.usage 没有被累计或输出,随后直接 continue;只有最后进入 completeFromTurn() 的 payload 会参与用量统计。

已通过真实 createZcodeHarness() 配合脚本客户端复现:第一轮 prose completion 报告 input/output 1000/50,第二轮有效输出报告 200/10,最终 usage_delta 和 cursor 只记录 200/10,而不是两轮总量 1200/60。三轮都返回 prose 时,最终 failure cursor 的 usage 全为零。

修复指令:

  1. 按原生协议的每轮 usage 语义维护 attempt 累计用量,不要在发送 correction 前丢弃已报告的用量。
  2. 让累计用量进入 normalized delivery / persisted cursor;纠正成功、纠正发送失败或重试耗尽时都应保留已消耗的用量,并避免重放时重复计数。
  3. 添加各轮用量不同的成功纠正与重试耗尽测试,同时断言 usage delta 总和及最终 cursor;仅断言事件类型不足以发现这个问题。

[P2] JSON 标量和数组会绕过纠正分支

位置:src/agents/zcode/harness.ts:106-109

responseContainsJsonObject() 只检查 extractJsonObject() 是否抛错,而后者会返回任何成功 JSON.parse() 的值。null[] 和 JSON 字符串并不是所要求的 JSON 对象,却被判定为包含对象,随后直接因 schema 校验失败而终结 attempt。

已用上述三种响应分别复现:即使队列里已有下一轮有效角色输出,也只发送一次初始 session/send,没有 correction,最终得到 invalid_structured_output。这不符合 PR 描述中“没有可提取 JSON 对象时进行最多两轮纠正”的条件。

修复指令:检查提取结果确实为非 null、非数组对象;添加 primitive / array 后接有效角色输出的边界测试,确认发出 correction 并成功完成。对象本身不符合角色 schema 时,仍可保留现有 fail-closed 行为。

Standards / 验证

人工规范审查没有额外 finding;工具检查发现一个需要处理的格式错误:test/agents/zcode/harness.test.ts:618correction 类型声明需要按 Biome 格式展开。可运行 bunx biome format --write test/agents/zcode/harness.test.ts,然后重跑 bun run check

  • bun run typecheck:通过。
  • bun run check:失败,停在上述格式错误。
  • bun test:首次为 403 pass / 1 skip / 1 fail,失败项是未改动的子进程退出测试;相关客户端测试单独重跑 12 pass,完整套件重跑为 404 pass / 1 skip / 0 fail。

汇总:Standards 人工 finding 0;Spec 2 项 P2。现有测试重跑通过,但两个已复现的行为问题和完整检查失败仍需处理。

Extend the in-session structured-output retry to responses whose JSON
object violates the role's strict schema, not only JSON-less prose.
Live runs of the ZCode backend showed the dominant failure shape is a
JSON reply that embeds the prompt's schema metadata as a "\$schema" key
or omits required fields, which strict validation rejects; the
correction prompt already restates the schema, so both shapes share one
retry budget (two corrections total) before the attempt fails through
the existing invalid_structured_output path. Implement commit
resolution stays outside the retry: a schema-valid draft whose commit
cannot be resolved is a workspace problem restating the schema cannot
correct.
@zhangweijian97 zhangweijian97 changed the title fix(zcode): retry JSON-less role turns in-session before failing fix(zcode): retry invalid structured output in-session before failing Sep 3, 2026
The native protocol reports usage per turn, but the correction branch
dropped each JSON-less turn's usage before sending the retry, so a
corrected attempt reported only the final turn's tokens and a
retry-exhausted attempt reported zero. Every successful completion now
adds to an attempt-scoped accumulator seeded from the persisted cursor
baseline: correction success, correction-send failure, and retry
exhaustion all publish the consumed tokens through a usage_delta
delivery whose cursor carries the total, and a replay from that cursor
never re-counts already-published usage.

Also adds boundary tests for JSON scalars and arrays (null, [], string),
which earn a correction round under the retry extension instead of
bypassing it, and expands the harness.test.ts correction type
declaration per Biome format.
@zhangweijian97

Copy link
Copy Markdown
Contributor Author

两项 P2 已在 76ca962 修复并推送(当前 head)。先说明 head 状态:本轮评审针对 0db3d4b,其后分支已前进到 43270f9——那是独立于本轮评审的扩展(真跑 ZCode 后端观察到主导失败形态是内嵌 \$schema 键或缺必填字段的 JSON 回复,已将重试覆盖到 schema 违例对象),本轮修复在其上完成。

[P2] 纠正轮次没有保留已报告的 token 用量

按三条修复指令落地:

  1. 每轮 turn.completed 到达时先把 mapZcodeUsage(event.data.usage) 累加进 attempt 级累计量,再进入纠正/完成判定——发送 correction 前不再丢弃已报告用量(harness.tsaddUsage 累计点)。
  2. 完成与失败路径共用该累计量:成功路径的 usage_delta 与最终 cursor 取累计总量;重试耗尽(invalid_structured_output)、纠正发送失败(zcode_retry_send_failed)、turn.failed 分类失败、review 快照失败与 blocked_policy 均经过统一的终态交付链——先补发 usage_delta 交付、再延迟发出终态交付(延迟构建是为了不在队列未消费时过早终结 attempt)。重放防双计:累计量以持久化 cursor 的 usage 为基线播种(reconcile 复活时 accumulatedUsage = cursor.usage),从已发布 usage 的 cursor 重放只会产生 orphaned_turn 终态、不再重复计数。
  3. 新增三个断言用量总和与最终 cursor 的测试(不是只断言事件类型):
    • 各轮用量不同的成功纠正:delta 与 cursor 均为两轮总量 1200/60
    • 各轮用量不同的重试耗尽:usage_delta + failed_infra 两事件,cursor 保留三轮总量 1230/62
    • 中途崩溃重放:从已发布 usage 的 cursor 起新进程 reconcile,orphaned_turn 的 cursor 保留总量且不重复发 usage_delta
    • 纠正发送失败:zcode_retry_send_failed 前先发 usage_delta,cursor 保留已消耗用量。

[P2] JSON 标量和数组会绕过纠正分支

43270f9 把判定重构为 decodeStructuredOutput(提取 + schema 校验合一,失败返回 undefined 进纠正),null/数组/字符串经 schema 校验失败已进入纠正路径。本轮补上您要求的三种形态边界测试(null / [] / JSON 字符串,后接有效角色输出):各形态均发出 correction 并成功完成。对象本身不符角色 schema 的情况,43270f9 起不再 fail-closed 而是同样进入重试(真跑观察到这是主导失败形态,纠正 prompt 已复述 schema)——若您倾向维持 fail-closed,可以回退该扩展,边界测试不受影响。

Standards / 验证

  • harness.test.ts:618 的 correction 类型声明已按 Biome 展开格式化。
  • bun run check(lint + typecheck + test)通过:410 pass / 1 skip / 0 fail(含 conformance 全量)。

@zhangweijian97

Copy link
Copy Markdown
Contributor Author

注意到 09-07 的重构(f530ecd)已将 ZCode 后端整体移除,转向 Pi-only 执行核心——这个 PR 的目标文件已不存在,先关闭。

补充一点:当时修的问题(ZCode 会话偶发返回无效 structured output,直接判 fail 之前应先在会话内重试)是真实使用中遇到过的,分析过程保留在这里,后续如果其他后端碰到类似问题可以参考。

另外恭喜 remote-task-workflow 落地 🎉

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.

2 participants