diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c445680cd3..9e798b5f262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Voice-call/media-stream: resolve the source IP from trusted forwarding headers for per-IP pending-connection limits when `webhookSecurity.trustForwardingHeaders` and `trustedProxyIPs` are configured, and reserve `maxConnections` capacity for in-flight WebSocket upgrades so concurrent handshakes can no longer momentarily exceed the operator-set cap. (#66027) Thanks @eleqtrizit. - Feishu/allowlist: canonicalize allowlist entries by explicit `user`/`chat` kind, strip repeated `feishu:`/`lark:` provider prefixes, and stop folding opaque Feishu IDs to lowercase, so allowlist matching no longer crosses user/chat namespaces or widens to case-insensitive ID matches the operator did not intend. (#66021) Thanks @eleqtrizit. - TTS/reply media: persist OpenClaw temp voice outputs into managed outbound media and allow them through reply-media normalization, so voice-note replies stop silently dropping. (#63511) Thanks @jetd1. +- Agents/OpenAI: recover embedded GPT-style runs when reasoning-only or empty turns need bounded continuation, with replay-safe retry gating and incomplete-turn fallback when no visible answer arrives. (#66167) thanks @jalehman ## 2026.4.12 diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 2ee07432e99..c45376b065e 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -5,18 +5,25 @@ import { loadRunOverflowCompactionHarness, mockedClassifyFailoverReason, mockedGlobalHookRunner, + mockedLog, mockedRunEmbeddedAttempt, overflowBaseRunParams, resetRunOverflowCompactionHarnessMocks, } from "./run.overflow-compaction.harness.js"; import { buildAttemptReplayMetadata, + DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT, + DEFAULT_REASONING_ONLY_RETRY_LIMIT, + EMPTY_RESPONSE_RETRY_INSTRUCTION, extractPlanningOnlyPlanDetails, isLikelyExecutionAckPrompt, PLANNING_ONLY_RETRY_INSTRUCTION, + REASONING_ONLY_RETRY_INSTRUCTION, resolveAckExecutionFastPathInstruction, + resolveEmptyResponseRetryInstruction, resolvePlanningOnlyRetryLimit, resolvePlanningOnlyRetryInstruction, + resolveReasoningOnlyRetryInstruction, STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, @@ -236,6 +243,268 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(retryInstruction).toContain("Do not restate the plan"); }); + it("retries reasoning-only GPT turns with a visible-answer continuation instruction", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_reasoning_only", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: ["Visible answer."], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "Visible answer." }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-reasoning-only-continuation", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + const secondCall = mockedRunEmbeddedAttempt.mock.calls[1]?.[0] as { prompt?: string }; + expect(secondCall.prompt).toContain(REASONING_ONLY_RETRY_INSTRUCTION); + expect(mockedLog.warn).toHaveBeenCalledWith( + expect.stringContaining("reasoning-only assistant turn detected"), + ); + }); + + it("does not retry reasoning-only turns after side effects", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_after_send", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-reasoning-only-after-side-effects", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("verify before retrying"); + }); + + it("does not retry reasoning-only turns when the assistant ended in error", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "error", + provider: "openai", + model: "gpt-5.4", + errorMessage: "provider failed after emitting reasoning", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_error_turn", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-reasoning-only-assistant-error", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("Please try again"); + }); + + it("does not retry reasoning-only turns for non-openai assistant metadata", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "anthropic", + model: "sonnet-4.6", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ + id: "rs_provider_mismatch", + type: "reasoning", + }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-reasoning-only-provider-mismatch", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("Please try again"); + }); + + it("retries generic empty GPT turns with a visible-answer continuation instruction", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: ["Visible answer."], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "Visible answer." }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-empty-response-continuation", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + const secondCall = mockedRunEmbeddedAttempt.mock.calls[1]?.[0] as { prompt?: string }; + expect(secondCall.prompt).toContain(EMPTY_RESPONSE_RETRY_INSTRUCTION); + expect(mockedLog.warn).toHaveBeenCalledWith(expect.stringContaining("empty response detected")); + }); + + it("surfaces an error after exhausting empty-response retries", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValue( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-empty-response-exhausted", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("Please try again"); + expect(mockedLog.warn).toHaveBeenCalledWith( + expect.stringContaining("empty response retries exhausted"), + ); + }); + + it("surfaces an error after exhausting reasoning-only retries without a visible answer", async () => { + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValue( + makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ + id: "rs_reasoning_exhausted", + type: "reasoning", + }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + reasoningLevel: "on", + runId: "run-reasoning-only-exhausted", + }); + + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(3); + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("Please try again"); + expect(mockedLog.warn).toHaveBeenCalledWith( + expect.stringContaining("reasoning-only retries exhausted"), + ); + }); + it("detects structured bullet-only plans with intent cues as planning-only GPT turns", () => { const retryInstruction = resolvePlanningOnlyRetryInstruction({ provider: "openai", @@ -436,6 +705,166 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { ).toBe("abandoned"); }); + it("detects reasoning-only GPT turns from signed thinking blocks", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_helper", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION); + }); + + it("does not retry reasoning-only GPT turns after side effects", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_side_effect", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBeNull(); + expect(DEFAULT_REASONING_ONLY_RETRY_LIMIT).toBe(2); + }); + + it("does not retry reasoning-only GPT turns when the assistant ended in error", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "error", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_helper_error", type: "reasoning" }), + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBeNull(); + }); + + it("does not retry reasoning-only GPT turns when visible assistant text already exists", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: ["Visible answer."], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ + id: "rs_helper_visible_text", + type: "reasoning", + }), + }, + { type: "text", text: "" }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBeNull(); + }); + + it("detects generic empty GPT turns without visible text", () => { + const retryInstruction = resolveEmptyResponseRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); + expect(DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT).toBe(1); + }); + + it("does not retry generic empty GPT turns after side effects", () => { + const retryInstruction = resolveEmptyResponseRetryInstruction({ + provider: "openai", + modelId: "gpt-5.4", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + didSendViaMessagingTool: true, + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "openai", + model: "gpt-5.4", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBeNull(); + }); + it("marks compaction-timeout retries as paused and replay-invalid", () => { const attempt = makeAttemptResult({ promptErrorSource: "compaction", diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c3c9efe5d9a..8f0398ed388 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -95,11 +95,15 @@ import { scrubAnthropicRefusalMagic, } from "./run/helpers.js"; import { + DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT, + DEFAULT_REASONING_ONLY_RETRY_LIMIT, resolveAckExecutionFastPathInstruction, - resolveIncompleteTurnPayloadText, extractPlanningOnlyPlanDetails, + resolveEmptyResponseRetryInstruction, + resolveIncompleteTurnPayloadText, resolvePlanningOnlyRetryLimit, resolvePlanningOnlyRetryInstruction, + resolveReasoningOnlyRetryInstruction, STRICT_AGENTIC_BLOCKED_TEXT, resolveReplayInvalidFlag, resolveRunLivenessState, @@ -442,6 +446,8 @@ export async function runEmbeddedPiAgent( }); const executionContract = strictAgenticActive ? "strict-agentic" : "default"; const maxPlanningOnlyRetryAttempts = resolvePlanningOnlyRetryLimit(executionContract); + const maxReasoningOnlyRetryAttempts = DEFAULT_REASONING_ONLY_RETRY_LIMIT; + const maxEmptyResponseRetryAttempts = DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT; const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 2; const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3; @@ -457,9 +463,13 @@ export async function runEmbeddedPiAgent( let runLoopIterations = 0; let overloadProfileRotations = 0; let planningOnlyRetryAttempts = 0; + let reasoningOnlyRetryAttempts = 0; + let emptyResponseRetryAttempts = 0; let sameModelIdleTimeoutRetries = 0; let lastRetryFailoverReason: FailoverReason | null = null; let planningOnlyRetryInstruction: string | null = null; + let reasoningOnlyRetryInstruction: string | null = null; + let emptyResponseRetryInstruction: string | null = null; const ackExecutionFastPathInstruction = resolveAckExecutionFastPathInstruction({ provider, modelId, @@ -643,6 +653,8 @@ export async function runEmbeddedPiAgent( const promptAdditions = [ ackExecutionFastPathInstruction, planningOnlyRetryInstruction, + reasoningOnlyRetryInstruction, + emptyResponseRetryInstruction, ].filter( (value): value is string => typeof value === "string" && value.trim().length > 0, ); @@ -1655,14 +1667,7 @@ export async function runEmbeddedPiAgent( }; } - // Detect incomplete turns where prompt() resolved prematurely and the - // runner would otherwise drop an empty reply. - const incompleteTurnText = resolveIncompleteTurnPayloadText({ - payloadCount: payloadsWithToolMedia?.length ?? 0, - aborted, - timedOut, - attempt, - }); + const payloadCount = payloadsWithToolMedia?.length ?? 0; const nextPlanningOnlyRetryInstruction = resolvePlanningOnlyRetryInstruction({ provider, modelId, @@ -1671,8 +1676,22 @@ export async function runEmbeddedPiAgent( timedOut, attempt, }); + const nextReasoningOnlyRetryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: activeErrorContext.provider, + modelId: activeErrorContext.model, + aborted, + timedOut, + attempt, + }); + const nextEmptyResponseRetryInstruction = resolveEmptyResponseRetryInstruction({ + provider: activeErrorContext.provider, + modelId: activeErrorContext.model, + payloadCount, + aborted, + timedOut, + attempt, + }); if ( - !incompleteTurnText && nextPlanningOnlyRetryInstruction && planningOnlyRetryAttempts < maxPlanningOnlyRetryAttempts ) { @@ -1710,6 +1729,51 @@ export async function runEmbeddedPiAgent( ); continue; } + if ( + !nextPlanningOnlyRetryInstruction && + nextReasoningOnlyRetryInstruction && + reasoningOnlyRetryAttempts < maxReasoningOnlyRetryAttempts + ) { + reasoningOnlyRetryAttempts += 1; + reasoningOnlyRetryInstruction = nextReasoningOnlyRetryInstruction; + log.warn( + `reasoning-only assistant turn detected: runId=${params.runId} sessionId=${params.sessionId} ` + + `provider=${activeErrorContext.provider}/${activeErrorContext.model} — retrying ${reasoningOnlyRetryAttempts}/${maxReasoningOnlyRetryAttempts} ` + + `with visible-answer continuation`, + ); + continue; + } + const reasoningOnlyRetriesExhausted = + !nextPlanningOnlyRetryInstruction && + nextReasoningOnlyRetryInstruction && + reasoningOnlyRetryAttempts >= maxReasoningOnlyRetryAttempts; + if ( + !nextPlanningOnlyRetryInstruction && + !nextReasoningOnlyRetryInstruction && + nextEmptyResponseRetryInstruction && + emptyResponseRetryAttempts < maxEmptyResponseRetryAttempts + ) { + emptyResponseRetryAttempts += 1; + emptyResponseRetryInstruction = nextEmptyResponseRetryInstruction; + log.warn( + `empty response detected: runId=${params.runId} sessionId=${params.sessionId} ` + + `provider=${activeErrorContext.provider}/${activeErrorContext.model} — retrying ${emptyResponseRetryAttempts}/${maxEmptyResponseRetryAttempts} ` + + `with visible-answer continuation`, + ); + continue; + } + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount, + aborted, + timedOut, + attempt, + }); + if (reasoningOnlyRetriesExhausted && !finalAssistantVisibleText) { + log.warn( + `reasoning-only retries exhausted: runId=${params.runId} sessionId=${params.sessionId} ` + + `provider=${activeErrorContext.provider}/${activeErrorContext.model} attempts=${reasoningOnlyRetryAttempts}/${maxReasoningOnlyRetryAttempts} — surfacing incomplete-turn error`, + ); + } if (!incompleteTurnText && nextPlanningOnlyRetryInstruction && strictAgenticActive) { log.warn( `strict-agentic run exhausted planning-only retries: runId=${params.runId} sessionId=${params.sessionId} ` + @@ -1759,6 +1823,64 @@ export async function runEmbeddedPiAgent( successfulCronAdds: attempt.successfulCronAdds, }; } + if (reasoningOnlyRetriesExhausted && !finalAssistantVisibleText) { + const replayInvalid = resolveReplayInvalidForAttempt( + "⚠️ Agent couldn't generate a response. Please try again.", + ); + const livenessState = resolveRunLivenessState({ + payloadCount: 0, + aborted, + timedOut, + attempt, + incompleteTurnText: "⚠️ Agent couldn't generate a response. Please try again.", + }); + attempt.setTerminalLifecycleMeta?.({ + replayInvalid, + livenessState, + }); + if (lastProfileId) { + await maybeMarkAuthProfileFailure({ + profileId: lastProfileId, + reason: resolveAuthProfileFailureReason(assistantFailoverReason), + }); + } + return { + payloads: [ + { + text: "⚠️ Agent couldn't generate a response. Please try again.", + isError: true, + }, + ], + meta: { + durationMs: Date.now() - started, + agentMeta, + aborted, + systemPromptReport: attempt.systemPromptReport, + finalPromptText: attempt.finalPromptText, + finalAssistantVisibleText, + finalAssistantRawText, + replayInvalid, + livenessState, + }, + didSendViaMessagingTool: attempt.didSendViaMessagingTool, + didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, + messagingToolSentTexts: attempt.messagingToolSentTexts, + messagingToolSentMediaUrls: attempt.messagingToolSentMediaUrls, + messagingToolSentTargets: attempt.messagingToolSentTargets, + successfulCronAdds: attempt.successfulCronAdds, + }; + } + if ( + !nextPlanningOnlyRetryInstruction && + !nextReasoningOnlyRetryInstruction && + nextEmptyResponseRetryInstruction && + emptyResponseRetryAttempts >= maxEmptyResponseRetryAttempts + ) { + log.warn( + `empty response retries exhausted: runId=${params.runId} sessionId=${params.sessionId} ` + + `provider=${activeErrorContext.provider}/${activeErrorContext.model} attempts=${emptyResponseRetryAttempts}/${maxEmptyResponseRetryAttempts} — surfacing incomplete-turn error`, + ); + } if (incompleteTurnText) { const replayInvalid = resolveReplayInvalidForAttempt(incompleteTurnText); const livenessState = resolveRunLivenessState({ diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 431c72d3162..9341af36100 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -1,7 +1,9 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { EmbeddedPiExecutionContract } from "../../../config/types.agent-defaults.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; import { isStrictAgenticSupportedProviderModel } from "../../execution-contract.js"; import { isLikelyMutatingToolName } from "../../tool-mutation.js"; +import { assessLastAssistantMessage } from "../thinking.js"; import type { EmbeddedRunLivenessState } from "../types.js"; import type { EmbeddedRunAttemptResult } from "./types.js"; @@ -12,7 +14,9 @@ type ReplayMetadataAttempt = Pick< type IncompleteTurnAttempt = Pick< EmbeddedRunAttemptResult, + | "assistantTexts" | "clientToolCall" + | "currentAttemptAssistant" | "yieldDetected" | "didSendDeterministicApprovalPrompt" | "lastToolError" @@ -73,6 +77,10 @@ const SINGLE_ACTION_RETRY_SAFE_TOOL_NAMES = new Set([ ]); 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 +// surfacing the existing incomplete-turn error path. +export const DEFAULT_REASONING_ONLY_RETRY_LIMIT = 2; +export const DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT = 1; const ACK_EXECUTION_NORMALIZED_SET = new Set([ "ok", "okay", @@ -121,6 +129,10 @@ const ACTIONABLE_PROMPT_REQUEST_RE = export const PLANNING_ONLY_RETRY_INSTRUCTION = "The previous assistant turn only described the plan. Do not restate the plan. Act now: take the first concrete tool action you can. If a real blocker prevents action, reply with the exact blocker in one sentence."; +export const REASONING_ONLY_RETRY_INSTRUCTION = + "The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch."; +export const EMPTY_RESPONSE_RETRY_INSTRUCTION = + "The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch."; export const ACK_EXECUTION_FAST_PATH_INSTRUCTION = "The latest user message is a short approval to proceed. Do not recap or restate the plan. Start with the first concrete tool action immediately. Keep any user-facing follow-up brief and natural."; export const STRICT_AGENTIC_BLOCKED_TEXT = @@ -166,7 +178,19 @@ export function resolveIncompleteTurnPayloadText(params: { hasAssistantVisibleText: params.payloadCount > 0, lastAssistant: params.attempt.lastAssistant, }); - if (!incompleteTerminalAssistant && stopReason !== "error") { + const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn( + params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant, + ); + const emptyResponseAssistant = isEmptyResponseAssistantTurn({ + payloadCount: params.payloadCount, + attempt: params.attempt, + }); + if ( + !incompleteTerminalAssistant && + !reasoningOnlyAssistant && + !emptyResponseAssistant && + stopReason !== "error" + ) { return null; } @@ -212,6 +236,128 @@ export function resolveRunLivenessState(params: { return "working"; } +export function isReasoningOnlyAssistantTurn(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + return assessLastAssistantMessage(message as AgentMessage) === "incomplete-text"; +} + +function isEmptyResponseAssistantTurn(params: { + payloadCount: number; + attempt: Pick< + IncompleteTurnAttempt, + "assistantTexts" | "currentAttemptAssistant" | "lastAssistant" + >; +}): boolean { + if (params.payloadCount !== 0) { + return false; + } + if (params.attempt.assistantTexts.join("\n\n").trim().length > 0) { + return false; + } + const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant; + if (!assistant) { + return true; + } + if (assistant.stopReason === "error") { + return false; + } + if ( + isIncompleteTerminalAssistantTurn({ + hasAssistantVisibleText: false, + lastAssistant: assistant, + }) || + isReasoningOnlyAssistantTurn(assistant) + ) { + return false; + } + return true; +} + +export function resolveReasoningOnlyRetryInstruction(params: { + provider?: string; + modelId?: string; + aborted: boolean; + timedOut: boolean; + attempt: IncompleteTurnAttempt; +}): string | null { + if ( + params.aborted || + params.timedOut || + params.attempt.clientToolCall || + params.attempt.yieldDetected || + params.attempt.didSendDeterministicApprovalPrompt || + params.attempt.lastToolError || + params.attempt.replayMetadata.hadPotentialSideEffects + ) { + return null; + } + + if ( + !shouldApplyPlanningOnlyRetryGuard({ + provider: params.provider, + modelId: params.modelId, + }) + ) { + return null; + } + + const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant; + if (params.attempt.assistantTexts.join("\n\n").trim().length > 0) { + return null; + } + if (assistant?.stopReason === "error") { + return null; + } + if (!isReasoningOnlyAssistantTurn(assistant)) { + return null; + } + + return REASONING_ONLY_RETRY_INSTRUCTION; +} + +export function resolveEmptyResponseRetryInstruction(params: { + provider?: string; + modelId?: string; + payloadCount: number; + aborted: boolean; + timedOut: boolean; + attempt: IncompleteTurnAttempt; +}): string | null { + if ( + params.aborted || + params.timedOut || + params.attempt.clientToolCall || + params.attempt.yieldDetected || + params.attempt.didSendDeterministicApprovalPrompt || + params.attempt.lastToolError || + params.attempt.replayMetadata.hadPotentialSideEffects + ) { + return null; + } + + if ( + !shouldApplyPlanningOnlyRetryGuard({ + provider: params.provider, + modelId: params.modelId, + }) + ) { + return null; + } + + if ( + !isEmptyResponseAssistantTurn({ + payloadCount: params.payloadCount, + attempt: params.attempt, + }) + ) { + return null; + } + + return EMPTY_RESPONSE_RETRY_INSTRUCTION; +} + function shouldApplyPlanningOnlyRetryGuard(params: { provider?: string; modelId?: string;