Skip to content

Commit b8128be

Browse files
committed
fix: guard synthetic placeholder fallback
1 parent 38b6f6a commit b8128be

2 files changed

Lines changed: 113 additions & 20 deletions

File tree

src/agents/embedded-agent-runner/result-fallback-classifier.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,85 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => {
481481
});
482482
});
483483

484+
it("keeps a synthetic-placeholder marker with visible final text out of fallback", () => {
485+
const result = classifyEmbeddedAgentRunResultForModelFallback({
486+
provider: "claude-cli",
487+
model: "Claude-Opus-4.7",
488+
result: {
489+
meta: {
490+
durationMs: 42,
491+
terminalReplyKind: "synthetic-placeholder",
492+
finalAssistantVisibleText: "Here is the requested answer.",
493+
},
494+
},
495+
});
496+
497+
expect(result).toBeNull();
498+
});
499+
500+
it("keeps a synthetic-placeholder marker with a visible payload out of fallback", () => {
501+
const result = classifyEmbeddedAgentRunResultForModelFallback({
502+
provider: "claude-cli",
503+
model: "Claude-Opus-4.7",
504+
result: {
505+
payloads: [{ text: "Here is the requested answer." }],
506+
meta: {
507+
durationMs: 42,
508+
terminalReplyKind: "synthetic-placeholder",
509+
},
510+
},
511+
});
512+
513+
expect(result).toBeNull();
514+
});
515+
516+
it("preserves hook block results with a synthetic-placeholder marker", () => {
517+
const result = classifyEmbeddedAgentRunResultForModelFallback({
518+
provider: "claude-cli",
519+
model: "Claude-Opus-4.7",
520+
result: {
521+
meta: {
522+
durationMs: 42,
523+
terminalReplyKind: "synthetic-placeholder",
524+
error: {
525+
kind: "hook_block",
526+
message: "Blocked by hook",
527+
},
528+
},
529+
},
530+
});
531+
532+
expect(result).toBeNull();
533+
});
534+
535+
it("keeps fallback-safe incomplete turns ahead of synthetic-placeholder classification", () => {
536+
const result = classifyEmbeddedAgentRunResultForModelFallback({
537+
provider: "claude-cli",
538+
model: "Claude-Opus-4.7",
539+
result: {
540+
payloads: [{ isError: true, text: "Agent couldn't generate a response." }],
541+
meta: {
542+
durationMs: 42,
543+
terminalReplyKind: "synthetic-placeholder",
544+
error: {
545+
kind: "incomplete_turn",
546+
message: "Agent couldn't generate a response.",
547+
fallbackSafe: true,
548+
terminalPresentation: true,
549+
},
550+
},
551+
},
552+
});
553+
554+
expect(result).toEqual({
555+
message: "Agent couldn't generate a response.",
556+
reason: "format",
557+
code: "incomplete_result",
558+
preserveResultOnExhaustion: true,
559+
preserveResultPriority: 1,
560+
});
561+
});
562+
484563
it("keeps explicit silent terminal replies out of fallback", () => {
485564
const result = classifyEmbeddedAgentRunResultForModelFallback({
486565
provider: "openai",

src/agents/embedded-agent-runner/result-fallback-classifier.ts

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ function hasNonTextVisiblePayloadContent(
9494
);
9595
}
9696

97+
/**
98+
* True when the run surfaced user-facing assistant output (non-silent final text or a visible
99+
* payload) that should be delivered instead of retried on a fallback model.
100+
*
101+
* Shared by the synthetic-placeholder guard and the generic visible-output guard so a
102+
* marked-but-visible turn (e.g. a `<synthetic>` placeholder followed by a real non-empty Claude
103+
* result) is never misclassified as an empty result and retried.
104+
*/
105+
function hasVisibleUserFacingReply(result: EmbeddedAgentRunResult): boolean {
106+
const finalVisibleText = result.meta.finalAssistantVisibleText;
107+
if (
108+
typeof finalVisibleText === "string" &&
109+
finalVisibleText.trim().length > 0 &&
110+
!isSilentReplyPayloadText(finalVisibleText)
111+
) {
112+
return true;
113+
}
114+
return hasVisibleAgentPayload(result, {
115+
includeErrorPayloads: false,
116+
includeReasoningPayloads: false,
117+
});
118+
}
119+
97120
function classifyGenericExternalRunFailurePayload(params: {
98121
provider: string;
99122
model: string;
@@ -201,13 +224,6 @@ export function classifyEmbeddedAgentRunResultForModelFallback(params: {
201224
if (hasCommittedOutboundDeliveryEvidence(params.result)) {
202225
return null;
203226
}
204-
if (params.result.meta.terminalReplyKind === "synthetic-placeholder") {
205-
return {
206-
message: `${params.provider}/${params.model} ended with a synthetic placeholder and no visible assistant reply`,
207-
reason: "format",
208-
code: "empty_result",
209-
};
210-
}
211227
if (params.result.meta.error?.kind === "hook_block") {
212228
// Hook blocks intentionally suppress normal agent output. Retrying on another model would
213229
// bypass a policy decision rather than recover a malformed model result.
@@ -222,19 +238,7 @@ export function classifyEmbeddedAgentRunResultForModelFallback(params: {
222238
if (genericExternalFailureClassification) {
223239
return genericExternalFailureClassification;
224240
}
225-
if (
226-
typeof params.result.meta.finalAssistantVisibleText === "string" &&
227-
params.result.meta.finalAssistantVisibleText.trim().length > 0 &&
228-
!isSilentReplyPayloadText(params.result.meta.finalAssistantVisibleText)
229-
) {
230-
return null;
231-
}
232-
if (
233-
hasVisibleAgentPayload(params.result, {
234-
includeErrorPayloads: false,
235-
includeReasoningPayloads: false,
236-
})
237-
) {
241+
if (hasVisibleUserFacingReply(params.result)) {
238242
return null;
239243
}
240244

@@ -252,6 +256,16 @@ export function classifyEmbeddedAgentRunResultForModelFallback(params: {
252256
preserveResultPriority: params.result.meta.error?.terminalPresentation === true ? 1 : 0,
253257
};
254258
}
259+
if (
260+
params.result.meta.terminalReplyKind === "synthetic-placeholder" &&
261+
!hasVisibleUserFacingReply(params.result)
262+
) {
263+
return {
264+
message: `${params.provider}/${params.model} ended with a synthetic placeholder and no visible assistant reply`,
265+
reason: "format",
266+
code: "empty_result",
267+
};
268+
}
255269
const harnessClassification = classifyHarnessResult({
256270
provider: params.provider,
257271
model: params.model,

0 commit comments

Comments
 (0)