fix(agent): blocked stale labels in last-turn yields

- Lifted the unknown-incremental-label check above the !useLastTurn guard so type: [findings], result: {} also rejects, preventing a sibling section's MAX_SCHEMA_RETRIES override from sneaking the stale section through finalization.\n- Extended the regression test with the last-turn payload case.\n\nRefs #3926
This commit is contained in:
roboomp
2026-06-30 22:20:09 +00:00
parent ef9c5ab521
commit ca3acaa61a
2 changed files with 30 additions and 16 deletions
+17 -16
View File
@@ -321,26 +321,27 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
const status = errorMessage !== undefined ? "aborted" : "success";
let schemaValidationOverridden = false;
// Unknown incremental labels are a hard contract mismatch with the closed caller
// schema. Reject before the last-turn short-circuit too: `type: ["findings"], result: {}`
// would otherwise be accepted as a typed last-turn incremental yield, then a sibling
// section's MAX_SCHEMA_RETRIES override flips schemaOverridden in finalization and the
// stale section rides along untouched.
if (status === "success" && isIncremental) {
const unknownLabels = this.#unknownIncrementalLabels(yieldType as string[]);
if (unknownLabels.length > 0) {
const validLabels =
this.#validateSection && this.#validateSection.size > 0
? formatYieldLabels([...this.#validateSection.keys()])
: "none";
throw new Error(
`Section ${formatYieldLabels(yieldType as string[])} uses unknown incremental yield label(s): ${formatYieldLabels(unknownLabels)}. Resubmit with one of the schema's labels: ${validLabels}.`,
);
}
}
if (status === "success" && !useLastTurn) {
if (data === null) {
throw new Error("data is required when yield indicates success");
}
// Unknown incremental labels are a hard contract mismatch with the closed
// caller schema, not a shape error a retry can fix. Reject them every time
// without touching the schema-retry counter so the override path never
// accepts a stale agent-native section name.
if (isIncremental) {
const unknownLabels = this.#unknownIncrementalLabels(yieldType as string[]);
if (unknownLabels.length > 0) {
const validLabels =
this.#validateSection && this.#validateSection.size > 0
? formatYieldLabels([...this.#validateSection.keys()])
: "none";
throw new Error(
`Section ${formatYieldLabels(yieldType as string[])} uses unknown incremental yield label(s): ${formatYieldLabels(unknownLabels)}. Resubmit with one of the schema's labels: ${validLabels}.`,
);
}
}
const sectionFailure = isIncremental
? this.#validateIncrementalSection(yieldType as string[], data)
: this.#validate
@@ -282,6 +282,19 @@ describe("YieldTool", () => {
);
}
// The last-turn short-circuit (`type: ["findings"], result: {}`) MUST also reject
// the unknown label. Otherwise the stale section silently accepts the last assistant
// text and rides along when a sibling section trips MAX_SCHEMA_RETRIES and
// schemaOverridden in finalization (issue #3927 follow-up review).
await expect(
tool.execute("call-native-reviewer-label-last-turn", {
type: ["findings"],
result: {},
} as never),
).rejects.toThrow(
/Section "findings" uses unknown incremental yield label\(s\): "findings"\. Resubmit with one of the schema's labels: "issue_key", "verdict", "blockers", "non_blocking_notes"\./,
);
// Schema-retry budget intact: a separate, shape-only mismatch still fires the
// first-attempt retry hint (`2 retry attempt(s) remain`), proving the unknown-label
// path didn't burn the override.