fix(agents): detect unsigned thinking-only stall when reasoning payload inflates payloadCount (#89874)

Summary:
- Merged fix(agents): detect unsigned thinking-only stall when reasoning payload inflates payloadCount after ClawSweeper review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head c613c3884f.
- Required merge gates passed before the squash merge.

Prepared head SHA: c613c3884f
Review: https://github.com/openclaw/openclaw/pull/89874#issuecomment-4630564594

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Chunyue Wang
2026-06-05 18:29:18 +08:00
committed by GitHub
parent 1a3ce7c2a8
commit 12a569109b
2 changed files with 136 additions and 5 deletions

View File

@@ -1528,6 +1528,63 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
expect(incompleteTurnText).toBeNull();
});
it("surfaces stall on clean stop with only an unsigned thinking payload (payloadCount=1, no visible text)", () => {
// Regression: unsigned thinking payloads increment payloadCount but carry no
// user-visible content. The visible-text guard must not suppress incomplete-turn
// detection when the model produced only a thinking block and no answer. (#89787)
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 1,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "qwen3.6-35b-a3b",
content: [
{
type: "thinking",
thinking: "let me plan the tool calls I need to make...",
// no signature — unsigned thinking block
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(incompleteTurnText).toContain("couldn't generate a response");
});
it("does not surface a stall when unsigned thinking accompanies visible text (payloadCount=1)", () => {
// When the model emits both a thinking block and a visible text answer, the turn
// succeeded and no stall should be surfaced even though thinking is unsigned.
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 1,
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: ["Here is the answer to your question."],
lastAssistant: {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "qwen3.6-35b-a3b",
content: [
{
type: "thinking",
thinking: "let me answer this...",
},
{ type: "text", text: "Here is the answer to your question." },
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(incompleteTurnText).toBeNull();
});
it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedAgent (#76477)", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
@@ -1696,6 +1753,59 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION);
});
it("retries unsigned thinking-only turns via the reasoning-only path (openai-completions)", () => {
const retryInstruction = resolveReasoningOnlyRetryInstruction({
provider: "openai",
modelId: "qwen3.6-35b-a3b",
modelApi: "openai-completions",
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "qwen3.6-35b-a3b",
content: [
{
type: "thinking",
thinking: "let me plan the tool calls I need to make...",
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION);
});
it("retries unsigned thinking-only Ollama turns via the reasoning-only path", () => {
const retryInstruction = resolveReasoningOnlyRetryInstruction({
provider: "ollama",
modelId: "gemma4:31b",
aborted: false,
timedOut: false,
attempt: makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
provider: "ollama",
model: "gemma4:31b",
content: [
{
type: "thinking",
thinking: "internal reasoning",
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
});
expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION);
});
it("retries unsigned-thinking Ollama turns via the empty-response path", () => {
const retryInstruction = resolveEmptyResponseRetryInstruction({
provider: "ollama",

View File

@@ -280,9 +280,17 @@ export function resolveIncompleteTurnPayloadText(params: {
// turn check in that case — the final post-tool response was never
// produced. (#76477)
const toolUseTerminal = params.attempt.lastAssistant?.stopReason === "toolUse";
const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
// Unsigned thinking payloads count toward payloadCount but carry no user-visible
// content; bypass the visible-text guard when unsigned thinking was the only output
// so that incomplete-turn stall detection fires below. (#89787)
const unsignedThinkingOnlyTerminal =
params.payloadCount !== 0 &&
!joinAssistantTexts(params.attempt.assistantTexts).length &&
isUnsignedThinkingOnlyAssistantTurn(assistant);
if (
(params.payloadCount !== 0 && !toolUseTerminal) ||
(params.payloadCount !== 0 && !toolUseTerminal && !unsignedThinkingOnlyTerminal) ||
(params.aborted && params.externalAbort) ||
params.timedOut ||
params.attempt.clientToolCalls ||
@@ -314,9 +322,7 @@ export function resolveIncompleteTurnPayloadText(params: {
hasAssistantVisibleText: params.payloadCount > 0,
lastAssistant: params.attempt.lastAssistant,
});
const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn(
params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant,
);
const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn(assistant);
const emptyResponseAssistant = isEmptyResponseAssistantTurn({
payloadCount: params.payloadCount,
attempt: params.attempt,
@@ -324,6 +330,7 @@ export function resolveIncompleteTurnPayloadText(params: {
if (
!incompleteTerminalAssistant &&
!reasoningOnlyAssistant &&
!unsignedThinkingOnlyTerminal &&
!emptyResponseAssistant &&
stopReason !== "error"
) {
@@ -534,6 +541,20 @@ function isReasoningOnlyAssistantTurn(message: unknown): boolean {
return assessLastAssistantMessage(message as AgentMessage) === "incomplete-text";
}
// Unsigned thinking blocks have no cryptographic signature; assessLastAssistantMessage
// returns "incomplete-thinking" for them. Empty content also returns "incomplete-thinking",
// so the content.length > 0 guard is required to distinguish the two cases.
function isUnsignedThinkingOnlyAssistantTurn(message: unknown): boolean {
if (message == null || typeof message !== "object") {
return false;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return false;
}
return assessLastAssistantMessage(message as AgentMessage) === "incomplete-thinking";
}
function isEmptyResponseAssistantTurn(params: {
payloadCount: number;
attempt: Pick<
@@ -669,7 +690,7 @@ export function resolveReasoningOnlyRetryInstruction(params: {
if (assistant?.stopReason === "error") {
return null;
}
if (!isReasoningOnlyAssistantTurn(assistant)) {
if (!isReasoningOnlyAssistantTurn(assistant) && !isUnsignedThinkingOnlyAssistantTurn(assistant)) {
return null;
}