fix: recover reasoning-only OpenAI turns (#66167)

* openclaw-11f.1: retry reasoning-only OpenAI turns

Regeneration-Prompt: |
  Patch the embedded runner so a signed reasoning-only assistant turn with no user-visible text is treated as recoverable instead of silently ending the run. Keep the change focused on the active OpenAI GPT-style path, retry the turn with an explicit visible-answer continuation instruction, and fall back to the existing incomplete-turn error handling only after retries are exhausted. Add regression coverage for the helper classification and for the outer run loop retry behavior, and keep unrelated provider behavior unchanged.

* openclaw-11f.1: address reasoning-only review feedback

Regeneration-Prompt: |
  Follow up on PR review feedback for the reasoning-only retry patch. Keep the fix narrow: move the retry limit into a named constant alongside the other retry-policy values, document why the limit is 2, and prevent reasoning-only auto-retries after any side effects so the runner falls back to the existing caution path instead of risking duplicate actions. Add regression coverage for the side-effect guard and the named limit behavior.

* openclaw-11f.1: drop local pebbles artifacts

Regeneration-Prompt: |
  Remove accidentally committed local pebbles tracker artifacts from the PR branch without changing runtime code. Keep the cleanup limited to deleting the tracked .pebbles files from version control, and rely on local git excludes for future pebbles activity so these files stay out of diffs.

* openclaw-11f.1: tighten reasoning-only retry guards

Regeneration-Prompt: |
  Follow up on the remaining review feedback for the reasoning-only retry path. Keep the fix narrow: do not auto-retry a reasoning-only turn when the assistant already terminated with stopReason error, and evaluate the OpenAI-specific retry guard against the provider/model metadata of the assistant turn that actually produced the partial output rather than the outer run configuration. Add regression coverage for both behaviors in the incomplete-turn runner tests.

* openclaw-11f.1: retry empty GPT turns once

Regeneration-Prompt: |
  Extend the embedded runner's GPT-style incomplete-turn recovery with a separate generic empty-response retry path. Keep it narrower than the existing reasoning-only recovery: one retry only, replay-safe only, no side effects, no assistant error turns, and scoped to the active assistant provider/model metadata. Add explicit warning logs when the empty-response retry triggers and when its single retry budget is exhausted, and add regression coverage for the success and exhaustion cases without changing broader provider fallback behavior.

* openclaw-11f.1: harden reasoning-only retry completion checks

Regeneration-Prompt: |
  Follow up on the remaining review feedback for the GPT-style recovery path. Keep the change narrow: only retry reasoning-only turns when there is no visible assistant answer yet, and if the reasoning-only retry budget is exhausted without any visible answer, surface the existing incomplete-turn error instead of treating reasoning-only payloads as a successful completion. Add focused regression coverage for both scenarios and preserve the adjacent empty-response retry behavior.

* openclaw-11f.1: preserve profile cooldown on retry exhaustion

Regeneration-Prompt: |
  Follow up on the final review comment for the GPT-style recovery path. Keep the change narrow: when the reasoning-only retry budget is exhausted and the run returns the incomplete-turn error early, preserve the same auth-profile cooldown behavior that the normal incomplete-turn branch already applies so multi-profile failover continues to work consistently. Verify the touched runner suites still pass.

* fix: recover GPT-style empty turns

Regeneration-Prompt: |
  Add the required changelog entry for the PR that hardens embedded GPT-style recovery of reasoning-only and empty-response turns. Keep the changelog update under ## Unreleased > ### Fixes, append-only, and include the PR number plus author attribution on the same line.
This commit is contained in:
Josh Lehman
2026-04-13 16:58:28 -07:00
committed by GitHub
parent 8d3f8a8268
commit 14779eaeb0
4 changed files with 709 additions and 11 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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({

View File

@@ -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;