fix: enable incomplete-turn recovery for Gemini

This commit is contained in:
Neerav Makwana
2026-04-24 22:38:32 -04:00
committed by Peter Steinberger
parent 73a6a2a6ab
commit 0ce93c9f1a
2 changed files with 98 additions and 1 deletions

View File

@@ -735,6 +735,33 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION);
});
it("detects reasoning-only Gemini turns from signed thinking blocks", () => {
const retryInstruction = resolveReasoningOnlyRetryInstruction({
provider: "google",
modelId: "gemini-2.5-pro",
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
provider: "google",
model: "gemini-2.5-pro",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "gemini_rs_helper", type: "reasoning" }),
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION);
});
it("treats exact NO_REPLY as a deliberate silent assistant reply", () => {
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 0,
@@ -916,6 +943,28 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT).toBe(1);
});
it("detects generic empty Gemini turns without visible text", () => {
const retryInstruction = resolveEmptyResponseRetryInstruction({
provider: "google-vertex",
modelId: "google/gemini-3.1-flash",
payloadCount: 0,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
provider: "google-vertex",
model: "gemini-3.1-flash",
content: [{ type: "text", text: "" }],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION);
});
it("does not retry generic empty GPT turns after side effects", () => {
const retryInstruction = resolveEmptyResponseRetryInstruction({
provider: "openai",
@@ -985,6 +1034,21 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
expect(result.meta.livenessState).toBe("working");
});
it("detects replay-safe planning-only Gemini turns", () => {
const retryInstruction = resolvePlanningOnlyRetryInstruction({
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro",
prompt: "Please inspect the code, make the change, and run the checks.",
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: ["I'll inspect the code, make the change, and run the checks."],
}),
});
expect(retryInstruction).toContain("Do not restate the plan");
});
it("does not misclassify a direct answer that says 'i'm not going to' as planning-only", () => {
const retryInstruction = resolvePlanningOnlyRetryInstruction({
provider: "openai-codex",

View File

@@ -77,6 +77,13 @@ const SINGLE_ACTION_RETRY_SAFE_TOOL_NAMES = new Set([
"glob",
"ls",
]);
const GEMINI_INCOMPLETE_TURN_PROVIDER_IDS = new Set([
"google",
"google-vertex",
"google-antigravity",
"google-gemini-cli",
]);
const GEMINI_INCOMPLETE_TURN_MODEL_ID_PATTERN = /^gemini(?:[.-]|$)/i;
const DEFAULT_PLANNING_ONLY_RETRY_LIMIT = 1;
const STRICT_AGENTIC_PLANNING_ONLY_RETRY_LIMIT = 2;
// Allow one immediate continuation plus one follow-up continuation before
@@ -394,12 +401,38 @@ function shouldApplyPlanningOnlyRetryGuard(params: {
if (params.executionContract === "strict-agentic") {
return true;
}
return isStrictAgenticSupportedProviderModel({
return isIncompleteTurnRecoverySupportedProviderModel({
provider: params.provider,
modelId: params.modelId,
});
}
function stripProviderPrefix(modelId: string): string {
const normalizedModelId = modelId.trim();
const match = /^([^/:]+)[/:](.+)$/.exec(normalizedModelId);
return (match?.[2] ?? normalizedModelId).toLowerCase();
}
function isIncompleteTurnRecoverySupportedProviderModel(params: {
provider?: string;
modelId?: string;
}): boolean {
if (
isStrictAgenticSupportedProviderModel({
provider: params.provider,
modelId: params.modelId,
})
) {
return true;
}
const provider = normalizeLowercaseStringOrEmpty(params.provider ?? "");
if (!GEMINI_INCOMPLETE_TURN_PROVIDER_IDS.has(provider)) {
return false;
}
const modelId = typeof params.modelId === "string" ? params.modelId : "";
return GEMINI_INCOMPLETE_TURN_MODEL_ID_PATTERN.test(stripProviderPrefix(modelId));
}
function normalizeAckPrompt(text: string): string {
const normalized = text
.normalize("NFKC")